diff --git a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryKeywordResolver.swift b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryKeywordResolver.swift new file mode 100644 index 0000000000..b825419fda --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryKeywordResolver.swift @@ -0,0 +1,77 @@ +import Foundation + +public struct SavedQueryKeywordResolution { + public let upserts: [Query] + // Local rows whose keyword is cleared before the upserts land: each is replaced or loses its keyword. + public let vacatedLocalIds: [UUID] + public let releasedLocalIds: [UUID] + public let releasedIncomingIds: [UUID] + + public var releasedIds: [UUID] { + releasedLocalIds + releasedIncomingIds + } +} + +extension SavedQueryKeywordResolution: Equatable where Query: Equatable {} + +extension SavedQueryKeywordResolution: Sendable where Query: Sendable {} + +// Every device runs this one rule on the same records, so they all release the same losers and converge. +public enum SavedQueryKeywordResolver { + public static func resolve( + incoming: [Query], + local: [Query] + ) -> SavedQueryKeywordResolution { + let upserts = latestVersions(of: incoming) + let incomingIds = Set(upserts.map(\.id)) + let bystanders = local.filter { !incomingIds.contains($0.id) } + let losingIds = keywordLosers(among: bystanders + upserts) + + return SavedQueryKeywordResolution( + upserts: upserts.map { losingIds.contains($0.id) ? releasingKeyword($0) : $0 }, + vacatedLocalIds: local.map(\.id).filter { incomingIds.contains($0) || losingIds.contains($0) }, + releasedLocalIds: bystanders.map(\.id).filter { losingIds.contains($0) }, + releasedIncomingIds: upserts.map(\.id).filter { losingIds.contains($0) } + ) + } + + public static func holdsKeywordFirst(_ lhs: Query, _ rhs: Query) -> Bool { + guard lhs.createdAt == rhs.createdAt else { return lhs.createdAt < rhs.createdAt } + return lhs.id.uuidString < rhs.id.uuidString + } + + private struct KeywordSlot: Hashable { + let keyword: String + let connectionId: UUID + } + + // A global keyword is never contested: the local save check already keeps it unique everywhere. + private static func slot(of query: Query) -> KeywordSlot? { + guard let keyword = query.keyword, let connectionId = query.connectionId else { return nil } + return KeywordSlot(keyword: keyword, connectionId: connectionId) + } + + private static func keywordLosers(among contenders: [Query]) -> Set { + var claimants: [KeywordSlot: [Query]] = [:] + for query in contenders { + guard let slot = slot(of: query) else { continue } + claimants[slot, default: []].append(query) + } + return Set(claimants.values.flatMap { $0.sorted(by: holdsKeywordFirst).dropFirst().map(\.id) }) + } + + private static func latestVersions(of queries: [Query]) -> [Query] { + var latestById: [UUID: Query] = [:] + var arrivalOrder: [UUID] = [] + for query in queries where latestById.updateValue(query, forKey: query.id) == nil { + arrivalOrder.append(query.id) + } + return arrivalOrder.compactMap { latestById[$0] } + } + + private static func releasingKeyword(_ query: Query) -> Query { + var released = query + released.keyword = nil + return released + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryOutline.swift b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryOutline.swift new file mode 100644 index 0000000000..29d6d6da70 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryOutline.swift @@ -0,0 +1,155 @@ +import Foundation + +public protocol SavedQueryRepresentable { + var id: UUID { get } + var name: String { get } + var query: String { get } + var keyword: String? { get set } + var folderId: UUID? { get } + var connectionId: UUID? { get } + var sortOrder: Int { get } + var createdAt: Date { get } +} + +public protocol SavedQueryFolderRepresentable: LibraryGroupRepresentable { + var connectionId: UUID? { get } +} + +public enum SavedQueryOutlineID: Hashable, Sendable { + case folder(UUID) + case query(UUID) +} + +public enum SavedQueryOutlineNode: Identifiable { + case folder(Folder, children: [SavedQueryOutlineNode]) + case query(Query) + + public var id: SavedQueryOutlineID { + switch self { + case .folder(let folder, _): + return .folder(folder.id) + case .query(let query): + return .query(query.id) + } + } + + public var children: [SavedQueryOutlineNode]? { + switch self { + case .folder(_, let children): + return children + case .query: + return nil + } + } +} + +extension SavedQueryOutlineNode: Equatable where Folder: Equatable, Query: Equatable {} + +extension SavedQueryOutlineNode: Hashable where Folder: Hashable, Query: Hashable {} + +extension SavedQueryOutlineNode: Sendable where Folder: Sendable, Query: Sendable {} + +public struct SavedQueryFolderRow: Identifiable { + public let folder: Folder + public let depth: Int + + public var id: UUID { folder.id } +} + +extension SavedQueryFolderRow: Equatable where Folder: Equatable {} + +extension SavedQueryFolderRow: Sendable where Folder: Sendable {} + +public enum SavedQueryOutline { + public static func isVisible(scope: UUID?, to connectionId: UUID) -> Bool { + scope == nil || scope == connectionId + } + + public static func visible(_ queries: [Query], to connectionId: UUID) -> [Query] { + queries.filter { isVisible(scope: $0.connectionId, to: connectionId) } + } + + public static func visible(_ folders: [Folder], to connectionId: UUID) -> [Folder] { + folders.filter { isVisible(scope: $0.connectionId, to: connectionId) } + } + + // Mac folders nest without limit, so the graph's depth checks are never asked here. + public static func build( + folders: [Folder], + queries: [Query] + ) -> [SavedQueryOutlineNode] { + let graph = LibraryGroupGraph(groups: folders) + let foldersById = firstById(folders) + let queriesByFolder = Dictionary(grouping: queries) { query -> UUID? in + query.folderId.flatMap { graph.contains($0) ? $0 : nil } + } + + var visited: Set = [] + func nodes(under parentId: UUID?) -> [SavedQueryOutlineNode] { + var result: [SavedQueryOutlineNode] = [] + for id in graph.sortedChildIds(of: parentId, mode: .manual) where visited.insert(id).inserted { + guard let folder = foldersById[id] else { continue } + result.append(.folder(folder, children: nodes(under: id))) + } + for query in (queriesByFolder[parentId] ?? []).sorted(by: queryPrecedes) { + result.append(.query(query)) + } + return result + } + return nodes(under: nil) + } + + public static func folderRows(_ folders: [Folder]) -> [SavedQueryFolderRow] { + let foldersById = firstById(folders) + return LibraryGroupGraph(groups: folders).flattened(mode: .manual).compactMap { entry in + foldersById[entry.id].map { SavedQueryFolderRow(folder: $0, depth: entry.depth) } + } + } + + // A folder stays while anything under it matches, so a hit never hides under an empty-looking ancestor. + public static func filter( + _ nodes: [SavedQueryOutlineNode], + matching text: String + ) -> [SavedQueryOutlineNode] { + let term = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return nodes } + return filtered(nodes, term: term) + } + + private static func filtered( + _ nodes: [SavedQueryOutlineNode], + term: String + ) -> [SavedQueryOutlineNode] { + nodes.compactMap { node -> SavedQueryOutlineNode? in + switch node { + case .query(let query): + return matches(query, term: term) ? node : nil + case .folder(let folder, let children): + let kept = filtered(children, term: term) + guard !kept.isEmpty || folder.name.localizedCaseInsensitiveContains(term) else { return nil } + return .folder(folder, children: kept) + } + } + } + + private static func matches(_ query: Query, term: String) -> Bool { + query.name.localizedCaseInsensitiveContains(term) + || query.keyword?.localizedCaseInsensitiveContains(term) == true + || query.query.localizedCaseInsensitiveContains(term) + } + + private static func queryPrecedes(_ lhs: Query, _ rhs: Query) -> Bool { + if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder } + let order = lhs.name.localizedStandardCompare(rhs.name) + if order != .orderedSame { return order == .orderedAscending } + return lhs.id.uuidString < rhs.id.uuidString + } + + private static func firstById(_ folders: [Folder]) -> [UUID: Folder] { + var byId: [UUID: Folder] = [:] + for folder in folders where byId[folder.id] == nil { + byId[folder.id] = folder + } + return byId + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Import/SavedQueryImportPlacement.swift b/apple/Packages/TableProCore/Sources/TableProImport/Import/SavedQueryImportPlacement.swift new file mode 100644 index 0000000000..e835fab99c --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProImport/Import/SavedQueryImportPlacement.swift @@ -0,0 +1,22 @@ +import Foundation + +public enum SavedQueryImportPlacement { + public static func holdableFolderPath(_ path: [PathComponent], for scope: UUID?) -> [PathComponent] { + var kept: [PathComponent] = [] + for component in path { + guard !component.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + SavedQueryScopeRule.folder(kept.last?.scope, canHold: component.scope) + else { break } + kept.append(component) + } + while let leaf = kept.last, !SavedQueryScopeRule.folder(leaf.scope, canHold: scope) { + kept.removeLast() + } + return kept + } + + public static func importedName(_ query: PlannedQuery) -> String { + let trimmed = query.name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? SavedQueryName.derived(from: query.sql) : query.name + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Import/SavedQueryRules.swift b/apple/Packages/TableProCore/Sources/TableProImport/Import/SavedQueryRules.swift index 59b7913afe..3e523c57bd 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Import/SavedQueryRules.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Import/SavedQueryRules.swift @@ -37,6 +37,12 @@ public enum SavedQueryKeyword { public static func shadowsSQLKeyword(_ keyword: String) -> Bool { reservedSQLKeywords.contains(keyword.lowercased()) } + + // A global keyword expands in every connection, so it collides with a holder anywhere. + public static func isAvailable(_ keyword: String, scope: UUID?, holderScopes: some Sequence) -> Bool { + guard normalized(keyword) != nil else { return true } + return !holderScopes.contains { scope == nil || $0 == nil || $0 == scope } + } } public enum SavedQueryName { @@ -156,11 +162,8 @@ public struct SavedQueryLedger: Sendable { } } - /// Mirrors `SQLFavoriteStorage.isKeywordAvailable`: a global keyword expands in every connection. private func isKeywordAvailable(_ keyword: String, scope: UUID?) -> Bool { - guard let holders = keywordScopes[keyword], !holders.isEmpty else { return true } - guard let scope else { return false } - return !holders.contains(nil) && !holders.contains(scope) + SavedQueryKeyword.isAvailable(keyword, scope: scope, holderScopes: keywordScopes[keyword] ?? []) } private static func normalizedName(_ name: String) -> String { diff --git a/apple/Packages/TableProCore/Sources/TableProModels/SavedQuery.swift b/apple/Packages/TableProCore/Sources/TableProModels/SavedQuery.swift new file mode 100644 index 0000000000..66e5bdd978 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProModels/SavedQuery.swift @@ -0,0 +1,54 @@ +import Foundation + +public struct SavedQuery: Identifiable, Codable, Hashable, Sendable { + public let id: UUID + public var name: String + public var query: String + public var keyword: String? + public var folderId: UUID? + public var connectionId: UUID? + public var sortOrder: Int + public let createdAt: Date + public var updatedAt: Date + + public init( + id: UUID = UUID(), + name: String, + query: String, + keyword: String? = nil, + folderId: UUID? = nil, + connectionId: UUID? = nil, + sortOrder: Int = 0, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + let now = Date() + self.id = id + self.name = name + self.query = query + self.keyword = keyword + self.folderId = folderId + self.connectionId = connectionId + self.sortOrder = sortOrder + self.createdAt = createdAt ?? now + self.updatedAt = updatedAt ?? now + } + + private enum CodingKeys: String, CodingKey { + case id, name, query, keyword, folderId, connectionId, sortOrder, createdAt, updatedAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + query = try container.decode(String.self, forKey: .query) + keyword = try container.decodeIfPresent(String.self, forKey: .keyword) + folderId = try container.decodeIfPresent(UUID.self, forKey: .folderId) + connectionId = try container.decodeIfPresent(UUID.self, forKey: .connectionId) + sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 + // The keyword contest is decided by createdAt, so a row without one is refused rather than dated now. + createdAt = try container.decode(Date.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProModels/SavedQueryFolder.swift b/apple/Packages/TableProCore/Sources/TableProModels/SavedQueryFolder.swift new file mode 100644 index 0000000000..8d03c2458f --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProModels/SavedQueryFolder.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct SavedQueryFolder: Identifiable, Codable, Hashable, Sendable { + public let id: UUID + public var name: String + public var parentId: UUID? + public var connectionId: UUID? + public var sortOrder: Int + public let createdAt: Date + public var updatedAt: Date + + public init( + id: UUID = UUID(), + name: String, + parentId: UUID? = nil, + connectionId: UUID? = nil, + sortOrder: Int = 0, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + let now = Date() + self.id = id + self.name = name + self.parentId = parentId + self.connectionId = connectionId + self.sortOrder = sortOrder + self.createdAt = createdAt ?? now + self.updatedAt = updatedAt ?? now + } + + private enum CodingKeys: String, CodingKey { + case id, name, parentId, connectionId, sortOrder, createdAt, updatedAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + parentId = try container.decodeIfPresent(UUID.self, forKey: .parentId) + connectionId = try container.decodeIfPresent(UUID.self, forKey: .connectionId) + sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 + createdAt = try container.decode(Date.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index ab6d195eee..3bac5f2d0d 100644 --- a/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -23,8 +23,7 @@ public enum SyncRecordMapper { SyncRecordType.parse(recordName: recordName) } - /// Reads the colour from the field both platforms now write, falling back to the hex `colorTag` - /// that iOS wrote before they converged. + // iOS wrote hex to `colorTag` before both apps converged on `color`. private static func color(from fields: SyncRecordFields) -> ConnectionColor { if let stored = fields[.color] as? String { return ConnectionColor(storedValue: stored) @@ -37,9 +36,7 @@ public enum SyncRecordMapper { // MARK: - Connection -> CKRecord - /// Builds the whole record from the model, which is also how a connection whose cached record - /// is gone gets pushed. Under `.changedKeys` only a named key reaches the server, so every field - /// `updateRecord` would clear is named here too, or a cleared group, tag or icon comes back. + // Under `.changedKeys` only a named key reaches the server, so every field `updateRecord` would clear is named here too. public static func toRecord(_ connection: DatabaseConnection, zoneID: CKRecordZone.ID) -> CKRecord { let id = recordID(type: .connection, id: connection.id.uuidString, in: zoneID) let record = CKRecord(recordType: SyncRecordType.connection.rawValue, recordID: id) @@ -409,4 +406,96 @@ public enum SyncRecordMapper { fields[.color] = tag.color.rawValue fields[.modifiedAtLocal] = Date() } + + // MARK: - Saved Query -> CKRecord + + // Built whole from the local query, so an emptied keyword, folder or connection is named and cleared on the server. + public static func toRecord(_ query: SavedQuery, zoneID: CKRecordZone.ID) -> CKRecord { + let id = recordID(type: .favorite, id: query.id.uuidString, in: zoneID) + let record = CKRecord(recordType: SyncRecordType.favorite.rawValue, recordID: id) + + let fields = record.fields(SQLFavoriteSyncField.self, absentValues: .clear) + fields[.favoriteId] = query.id.uuidString + fields[.name] = query.name + fields[.query] = query.query + fields[.keyword] = query.keyword + fields[.folderId] = query.folderId?.uuidString + fields[.connectionId] = query.connectionId?.uuidString + fields[.sortOrder] = Int64(query.sortOrder) + fields[.createdAt] = query.createdAt + fields[.updatedAt] = query.updatedAt + fields[.modifiedAtLocal] = Date() + fields[.schemaVersion] = schemaVersion + + return record + } + + // MARK: - CKRecord -> Saved Query + + public static func toSavedQuery(_ record: CKRecord) -> SavedQuery? { + let fields = record.fields(SQLFavoriteSyncField.self) + guard let idString = fields[.favoriteId] as? String, + let id = UUID(uuidString: idString), + let name = fields[.name] as? String, + let query = fields[.query] as? String + else { + logger.warning("Failed to decode saved query from CKRecord: missing required fields") + return nil + } + + return SavedQuery( + id: id, + name: name, + query: query, + keyword: fields[.keyword] as? String, + folderId: (fields[.folderId] as? String).flatMap(UUID.init(uuidString:)), + connectionId: (fields[.connectionId] as? String).flatMap(UUID.init(uuidString:)), + sortOrder: Int(fields[.sortOrder] as? Int64 ?? 0), + createdAt: fields[.createdAt] as? Date, + updatedAt: fields[.updatedAt] as? Date + ) + } + + // MARK: - Saved Query Folder -> CKRecord + + public static func toRecord(_ folder: SavedQueryFolder, zoneID: CKRecordZone.ID) -> CKRecord { + let id = recordID(type: .favoriteFolder, id: folder.id.uuidString, in: zoneID) + let record = CKRecord(recordType: SyncRecordType.favoriteFolder.rawValue, recordID: id) + + let fields = record.fields(SQLFavoriteFolderSyncField.self, absentValues: .clear) + fields[.folderId] = folder.id.uuidString + fields[.name] = folder.name + fields[.parentId] = folder.parentId?.uuidString + fields[.connectionId] = folder.connectionId?.uuidString + fields[.sortOrder] = Int64(folder.sortOrder) + fields[.createdAt] = folder.createdAt + fields[.updatedAt] = folder.updatedAt + fields[.modifiedAtLocal] = Date() + fields[.schemaVersion] = schemaVersion + + return record + } + + // MARK: - CKRecord -> Saved Query Folder + + public static func toSavedQueryFolder(_ record: CKRecord) -> SavedQueryFolder? { + let fields = record.fields(SQLFavoriteFolderSyncField.self) + guard let idString = fields[.folderId] as? String, + let id = UUID(uuidString: idString), + let name = fields[.name] as? String + else { + logger.warning("Failed to decode saved query folder from CKRecord: missing required fields") + return nil + } + + return SavedQueryFolder( + id: id, + name: name, + parentId: (fields[.parentId] as? String).flatMap(UUID.init(uuidString:)), + connectionId: (fields[.connectionId] as? String).flatMap(UUID.init(uuidString:)), + sortOrder: Int(fields[.sortOrder] as? Int64 ?? 0), + createdAt: fields[.createdAt] as? Date, + updatedAt: fields[.updatedAt] as? Date + ) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProConnectionLibraryTests/SavedQueryKeywordResolverTests.swift b/apple/Packages/TableProCore/Tests/TableProConnectionLibraryTests/SavedQueryKeywordResolverTests.swift new file mode 100644 index 0000000000..bc0993ddfa --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProConnectionLibraryTests/SavedQueryKeywordResolverTests.swift @@ -0,0 +1,144 @@ +import Foundation +@testable import TableProConnectionLibrary +import Testing + +private struct ResolverQuery: SavedQueryRepresentable, Equatable { + var id = UUID() + var name = "Query" + var query = "SELECT 1" + var keyword: String? + var folderId: UUID? + var connectionId: UUID? + var sortOrder = 0 + var createdAt: Date +} + +struct SavedQueryKeywordResolverTests { + private let connectionId = UUID() + private let older = Date(timeIntervalSince1970: 1_000) + private let newer = Date(timeIntervalSince1970: 2_000) + + private func query( + id: UUID = UUID(), + keyword: String?, + connectionId: UUID?, + createdAt: Date + ) -> ResolverQuery { + ResolverQuery(id: id, keyword: keyword, connectionId: connectionId, createdAt: createdAt) + } + + @Test("Nothing is released when no two queries share a keyword in one connection") + func noConflictReleasesNothing() { + let local = query(keyword: "rev", connectionId: connectionId, createdAt: older) + let incoming = query(keyword: "cost", connectionId: connectionId, createdAt: newer) + + let resolution = SavedQueryKeywordResolver.resolve(incoming: [incoming], local: [local]) + + #expect(resolution.upserts == [incoming]) + #expect(resolution.releasedIds.isEmpty) + #expect(resolution.vacatedLocalIds.isEmpty) + } + + @Test("One keyword in two connections is not a conflict") + func differentConnectionsDoNotCompete() { + let local = query(keyword: "rev", connectionId: UUID(), createdAt: older) + let incoming = query(keyword: "rev", connectionId: connectionId, createdAt: newer) + + let resolution = SavedQueryKeywordResolver.resolve(incoming: [incoming], local: [local]) + + #expect(resolution.upserts == [incoming]) + #expect(resolution.releasedIds.isEmpty) + } + + @Test("Global queries never compete for a keyword") + func globalQueriesDoNotCompete() { + let first = query(keyword: "rev", connectionId: nil, createdAt: older) + let second = query(keyword: "rev", connectionId: nil, createdAt: newer) + let scoped = query(keyword: "rev", connectionId: connectionId, createdAt: newer) + + let resolution = SavedQueryKeywordResolver.resolve(incoming: [first, second, scoped], local: []) + + #expect(resolution.upserts == [first, second, scoped]) + #expect(resolution.releasedIds.isEmpty) + } + + @Test("The older of a local and an incoming holder keeps the keyword") + func olderLocalHolderWins() { + let local = query(keyword: "rev", connectionId: connectionId, createdAt: older) + let incoming = query(keyword: "rev", connectionId: connectionId, createdAt: newer) + + let resolution = SavedQueryKeywordResolver.resolve(incoming: [incoming], local: [local]) + + #expect(resolution.upserts.first?.keyword == nil) + #expect(resolution.upserts.first?.name == incoming.name) + #expect(resolution.releasedIncomingIds == [incoming.id]) + #expect(resolution.releasedLocalIds.isEmpty) + #expect(resolution.vacatedLocalIds.isEmpty) + } + + @Test("A newer local holder gives its keyword up to an older incoming one") + func newerLocalHolderIsVacated() { + let local = query(keyword: "rev", connectionId: connectionId, createdAt: newer) + let incoming = query(keyword: "rev", connectionId: connectionId, createdAt: older) + + let resolution = SavedQueryKeywordResolver.resolve(incoming: [incoming], local: [local]) + + #expect(resolution.upserts == [incoming]) + #expect(resolution.releasedLocalIds == [local.id]) + #expect(resolution.vacatedLocalIds == [local.id]) + #expect(resolution.releasedIds == [local.id]) + } + + @Test("Two holders created at the same moment are settled by id") + func tieIsSettledById() throws { + let lowId = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let highId = try #require(UUID(uuidString: "FFFFFFFF-0000-0000-0000-000000000001")) + let high = query(id: highId, keyword: "rev", connectionId: connectionId, createdAt: older) + let low = query(id: lowId, keyword: "rev", connectionId: connectionId, createdAt: older) + + let resolution = SavedQueryKeywordResolver.resolve(incoming: [high, low], local: []) + + #expect(resolution.releasedIncomingIds == [highId]) + #expect(resolution.upserts.first { $0.id == lowId }?.keyword == "rev") + #expect(SavedQueryKeywordResolver.holdsKeywordFirst(low, high)) + #expect(!SavedQueryKeywordResolver.holdsKeywordFirst(high, low)) + } + + @Test("A local query the batch updates is judged by its incoming version") + func incomingVersionReplacesTheLocalOne() { + let id = UUID() + let local = query(id: id, keyword: "rev", connectionId: connectionId, createdAt: older) + let renamed = query(id: id, keyword: "cost", connectionId: connectionId, createdAt: older) + let taker = query(keyword: "rev", connectionId: connectionId, createdAt: newer) + + let resolution = SavedQueryKeywordResolver.resolve(incoming: [taker, renamed], local: [local]) + + #expect(resolution.upserts == [taker, renamed]) + #expect(resolution.vacatedLocalIds == [id]) + #expect(resolution.releasedIds.isEmpty) + } + + @Test("A record listed twice in one pull is written once, as its last version") + func duplicateArrivalKeepsTheLastVersion() { + let id = UUID() + let first = query(id: id, keyword: "rev", connectionId: connectionId, createdAt: older) + let last = query(id: id, keyword: "cost", connectionId: connectionId, createdAt: older) + + let resolution = SavedQueryKeywordResolver.resolve(incoming: [first, last], local: []) + + #expect(resolution.upserts == [last]) + #expect(resolution.releasedIds.isEmpty) + } + + @Test("Two devices resolving the same records release the same loser") + func resolutionConverges() { + let oldest = query(keyword: "rev", connectionId: connectionId, createdAt: older) + let newest = query(keyword: "rev", connectionId: connectionId, createdAt: newer) + + let here = SavedQueryKeywordResolver.resolve(incoming: [newest], local: [oldest]) + let there = SavedQueryKeywordResolver.resolve(incoming: [oldest], local: [newest]) + + #expect(here.releasedIds == [newest.id]) + #expect(there.releasedIds == [newest.id]) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProConnectionLibraryTests/SavedQueryOutlineTests.swift b/apple/Packages/TableProCore/Tests/TableProConnectionLibraryTests/SavedQueryOutlineTests.swift new file mode 100644 index 0000000000..9d92be8825 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProConnectionLibraryTests/SavedQueryOutlineTests.swift @@ -0,0 +1,209 @@ +import Foundation +@testable import TableProConnectionLibrary +import Testing + +private struct OutlineQuery: SavedQueryRepresentable, Hashable { + var id = UUID() + var name: String + var query = "SELECT 1" + var keyword: String? + var folderId: UUID? + var connectionId: UUID? + var sortOrder = 0 + var createdAt = Date(timeIntervalSince1970: 1_000) +} + +private struct OutlineFolder: SavedQueryFolderRepresentable, Hashable { + var id = UUID() + var name: String + var parentId: UUID? + var connectionId: UUID? + var sortOrder = 0 +} + +private typealias Node = SavedQueryOutlineNode + +private indirect enum Shape: Equatable { + case folder(String, [Shape]) + case query(String) +} + +private func shape(_ nodes: [Node]) -> [Shape] { + nodes.map { node in + switch node { + case .folder(let folder, let children): + return .folder(folder.name, shape(children)) + case .query(let query): + return .query(query.name) + } + } +} + +struct SavedQueryOutlineTests { + private let connectionId = UUID() + + @Test("Folders come before queries, each ordered by sort order, then name, then id") + func order() { + let beta = OutlineFolder(name: "Beta") + let alpha = OutlineFolder(name: "alpha") + let pinned = OutlineFolder(name: "Zulu", sortOrder: -1) + let queries = [ + OutlineQuery(name: "Query 10"), + OutlineQuery(name: "Query 9"), + OutlineQuery(name: "Last", sortOrder: 1) + ] + + let nodes = SavedQueryOutline.build(folders: [beta, alpha, pinned], queries: queries) + + #expect(shape(nodes) == [ + .folder("Zulu", []), .folder("alpha", []), .folder("Beta", []), + .query("Query 9"), .query("Query 10"), .query("Last") + ]) + } + + @Test("A query sits in its folder, and a folder under its parent") + func nesting() { + let reports = OutlineFolder(name: "Reports") + let daily = OutlineFolder(name: "Daily", parentId: reports.id) + let query = OutlineQuery(name: "Users", folderId: daily.id) + + let nodes = SavedQueryOutline.build(folders: [daily, reports], queries: [query]) + + #expect(shape(nodes) == [.folder("Reports", [.folder("Daily", [.query("Users")])])]) + } + + @Test("A query whose folder is not here, and a folder whose parent is not here, sit at the root") + func missingParentGoesToRoot() { + let orphan = OutlineFolder(name: "Orphan", parentId: UUID()) + let query = OutlineQuery(name: "Lost", folderId: UUID()) + + let nodes = SavedQueryOutline.build(folders: [orphan], queries: [query]) + + #expect(shape(nodes) == [.folder("Orphan", []), .query("Lost")]) + } + + @Test("Every folder on a parent cycle sits at the root and keeps its queries") + func cycleGoesToRoot() { + var first = OutlineFolder(name: "First") + let second = OutlineFolder(name: "Second", parentId: first.id) + first.parentId = second.id + let query = OutlineQuery(name: "Inside", folderId: second.id) + + let nodes = SavedQueryOutline.build(folders: [first, second], queries: [query]) + + #expect(shape(nodes) == [.folder("First", []), .folder("Second", [.query("Inside")])]) + } + + @Test("Folders nest past the connection library's depth cap") + func depthIsUnlimited() { + let one = OutlineFolder(name: "1") + let two = OutlineFolder(name: "2", parentId: one.id) + let three = OutlineFolder(name: "3", parentId: two.id) + let four = OutlineFolder(name: "4", parentId: three.id) + let five = OutlineFolder(name: "5", parentId: four.id) + let query = OutlineQuery(name: "Deep", folderId: five.id) + + let nodes = SavedQueryOutline.build(folders: [five, four, three, two, one], queries: [query]) + + #expect(shape(nodes) == [ + .folder("1", [.folder("2", [.folder("3", [.folder("4", [.folder("5", [.query("Deep")])])])])]) + ]) + #expect(SavedQueryOutline.folderRows([five, four, three, two, one]).map(\.depth) == [0, 1, 2, 3, 4]) + } + + @Test("A connection sees its own records and the global ones") + func visibility() { + let own = OutlineQuery(name: "Own", connectionId: connectionId) + let global = OutlineQuery(name: "Global") + let other = OutlineQuery(name: "Other", connectionId: UUID()) + let ownFolder = OutlineFolder(name: "Own", connectionId: connectionId) + let globalFolder = OutlineFolder(name: "Global") + let otherFolder = OutlineFolder(name: "Other", connectionId: UUID()) + + #expect(SavedQueryOutline.visible([own, global, other], to: connectionId).map(\.name) == ["Own", "Global"]) + #expect(SavedQueryOutline.visible([ownFolder, globalFolder, otherFolder], to: connectionId).map(\.name) == ["Own", "Global"]) + #expect(SavedQueryOutline.isVisible(scope: nil, to: connectionId)) + #expect(!SavedQueryOutline.isVisible(scope: UUID(), to: connectionId)) + } + + @Test("A global query filed in another connection's folder shows at the root") + func globalQueryInHiddenFolderShowsAtRoot() { + let hidden = OutlineFolder(name: "Theirs", connectionId: UUID()) + let query = OutlineQuery(name: "Shared", folderId: hidden.id) + let folders = SavedQueryOutline.visible([hidden], to: connectionId) + let queries = SavedQueryOutline.visible([query], to: connectionId) + + let nodes = SavedQueryOutline.build(folders: folders, queries: queries) + + #expect(shape(nodes) == [.query("Shared")]) + } + + @Test("Folder rows list every folder in outline order with its depth") + func folderRows() { + let reports = OutlineFolder(name: "Reports") + let daily = OutlineFolder(name: "Daily", parentId: reports.id) + let archive = OutlineFolder(name: "Archive") + + let rows = SavedQueryOutline.folderRows([daily, reports, archive]) + + #expect(rows.map(\.folder.name) == ["Archive", "Reports", "Daily"]) + #expect(rows.map(\.depth) == [0, 0, 1]) + #expect(rows.map(\.id) == [archive.id, reports.id, daily.id]) + } + + @Test("Search matches a query's name, keyword or SQL, ignoring case") + func searchMatchesNameKeywordAndSQL() { + let queries = [ + OutlineQuery(name: "Daily users"), + OutlineQuery(name: "Revenue", keyword: "rev"), + OutlineQuery(name: "Orders", query: "SELECT * FROM orders WHERE status = 'open'"), + OutlineQuery(name: "Unrelated") + ] + let nodes = SavedQueryOutline.build(folders: [OutlineFolder](), queries: queries) + + #expect(shape(SavedQueryOutline.filter(nodes, matching: "USERS")) == [.query("Daily users")]) + #expect(shape(SavedQueryOutline.filter(nodes, matching: "REV")) == [.query("Revenue")]) + #expect(shape(SavedQueryOutline.filter(nodes, matching: "status")) == [.query("Orders")]) + } + + @Test("Search keeps a folder holding a match, and a matching folder with only its matching children") + func searchKeepsFolders() { + let reports = OutlineFolder(name: "Reports") + let daily = OutlineFolder(name: "Daily", parentId: reports.id) + let empty = OutlineFolder(name: "Empty") + let queries = [ + OutlineQuery(name: "Active users", folderId: daily.id), + OutlineQuery(name: "Revenue", folderId: daily.id), + OutlineQuery(name: "Monthly", folderId: reports.id) + ] + let nodes = SavedQueryOutline.build(folders: [reports, daily, empty], queries: queries) + + #expect(shape(SavedQueryOutline.filter(nodes, matching: "active")) == [ + .folder("Reports", [.folder("Daily", [.query("Active users")])]) + ]) + #expect(shape(SavedQueryOutline.filter(nodes, matching: "reports")) == [.folder("Reports", [])]) + let unmatched = SavedQueryOutline.filter(nodes, matching: "nothing") + #expect(unmatched.isEmpty) + } + + @Test("A blank search shows everything") + func blankSearchKeepsEverything() { + let folder = OutlineFolder(name: "Reports") + let nodes = SavedQueryOutline.build(folders: [folder], queries: [OutlineQuery(name: "Daily", folderId: folder.id)]) + + #expect(SavedQueryOutline.filter(nodes, matching: " ") == nodes) + } + + @Test("A node's id tells a folder from a query with the same UUID") + func nodeIdentity() { + let id = UUID() + let folder = Node.folder(OutlineFolder(id: id, name: "Reports"), children: []) + let query = Node.query(OutlineQuery(id: id, name: "Daily")) + + #expect(folder.id == .folder(id)) + #expect(query.id == .query(id)) + #expect(folder.id != query.id) + #expect(folder.children?.isEmpty == true) + #expect(query.children == nil) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProImportTests/SavedQueryImportPlacementTests.swift b/apple/Packages/TableProCore/Tests/TableProImportTests/SavedQueryImportPlacementTests.swift new file mode 100644 index 0000000000..237e165442 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProImportTests/SavedQueryImportPlacementTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing + +@testable import TableProImport + +struct SavedQueryImportPlacementTests { + private let scope = UUID() + private let otherScope = UUID() + + private func folder(_ name: String, scope: UUID? = nil) -> PathComponent { + PathComponent(name: name, scope: scope, color: nil) + } + + private func planned(_ name: String, sql: String = "SELECT 1") -> PlannedQuery { + PlannedQuery(ref: "q1", name: name, sql: sql, keyword: nil, connectionId: nil, folderPath: []) + } + + @Test("A path every folder of which can hold the query is kept whole") + func holdablePathIsKept() { + let path = [folder("Team"), folder("Mine", scope: scope)] + + #expect(SavedQueryImportPlacement.holdableFolderPath(path, for: scope) == path) + } + + @Test("A global query drops a scoped leaf it cannot sit in") + func globalQueryTrimsScopedLeaf() { + let path = [folder("Team"), folder("Mine", scope: scope)] + + #expect(SavedQueryImportPlacement.holdableFolderPath(path, for: nil) == [folder("Team")]) + } + + @Test("The chain is cut where a scoped folder would hold a global one") + func chainIsCutAtScopeMismatch() { + let path = [folder("Scoped", scope: scope), folder("Shared"), folder("Below", scope: scope)] + + #expect(SavedQueryImportPlacement.holdableFolderPath(path, for: scope) == [folder("Scoped", scope: scope)]) + } + + @Test("A folder of another connection cuts the chain and the rest of it") + func otherScopeCutsTheChain() { + let path = [folder("Mine", scope: scope), folder("Theirs", scope: otherScope)] + + #expect(SavedQueryImportPlacement.holdableFolderPath(path, for: scope) == [folder("Mine", scope: scope)]) + #expect(SavedQueryImportPlacement.holdableFolderPath(path, for: otherScope).isEmpty) + } + + @Test("A blank folder name ends the path") + func blankNameEndsThePath() { + let path = [folder("Team"), folder(" "), folder("After")] + + #expect(SavedQueryImportPlacement.holdableFolderPath(path, for: nil) == [folder("Team")]) + } + + @Test("A blank imported name is taken from the SQL") + func blankNameIsDerived() { + #expect(SavedQueryImportPlacement.importedName(planned(" ", sql: "-- Active users\nSELECT * FROM users")) == "Active users") + #expect(SavedQueryImportPlacement.importedName(planned("", sql: "SELECT 2")) == "SELECT 2") + } + + @Test("A named import keeps its name as the file spells it") + func namedImportKeepsItsName() { + #expect(SavedQueryImportPlacement.importedName(planned(" Daily ")) == " Daily ") + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProImportTests/SavedQueryLedgerTests.swift b/apple/Packages/TableProCore/Tests/TableProImportTests/SavedQueryLedgerTests.swift index 161df8a5e3..fa9f697be7 100644 --- a/apple/Packages/TableProCore/Tests/TableProImportTests/SavedQueryLedgerTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProImportTests/SavedQueryLedgerTests.swift @@ -137,4 +137,33 @@ struct SavedQueryLedgerTests { #expect(SavedQueryName.derived(from: String(repeating: "a", count: 80)) == String(repeating: "a", count: 50)) #expect(SavedQueryName.derived(from: " \n\r\n") == "Untitled") } + + @Test("A keyword nobody holds is available in any scope") + func unheldKeywordIsAvailable() { + #expect(SavedQueryKeyword.isAvailable("dau", scope: nil, holderScopes: [])) + #expect(SavedQueryKeyword.isAvailable("dau", scope: scope, holderScopes: [])) + } + + @Test("A global keyword collides with a holder in any connection") + func globalKeywordCollidesWithScopedHolder() { + #expect(!SavedQueryKeyword.isAvailable("dau", scope: nil, holderScopes: [scope])) + #expect(!SavedQueryKeyword.isAvailable("dau", scope: nil, holderScopes: [nil])) + } + + @Test("A scoped keyword collides with a global holder and one in its own connection") + func scopedKeywordCollidesWithGlobalAndSameScope() { + #expect(!SavedQueryKeyword.isAvailable("dau", scope: scope, holderScopes: [nil])) + #expect(!SavedQueryKeyword.isAvailable("dau", scope: scope, holderScopes: [scope])) + #expect(!SavedQueryKeyword.isAvailable("dau", scope: scope, holderScopes: [otherScope, scope])) + } + + @Test("A scoped keyword held only in another connection is available") + func otherScopeIsFree() { + #expect(SavedQueryKeyword.isAvailable("dau", scope: scope, holderScopes: [otherScope])) + } + + @Test("An empty keyword is no keyword and never collides") + func emptyKeywordIsAvailable() { + #expect(SavedQueryKeyword.isAvailable(" ", scope: nil, holderScopes: [nil])) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProModelsTests/SavedQueryCodingTests.swift b/apple/Packages/TableProCore/Tests/TableProModelsTests/SavedQueryCodingTests.swift new file mode 100644 index 0000000000..536b3cf8cc --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProModelsTests/SavedQueryCodingTests.swift @@ -0,0 +1,128 @@ +import Foundation +import Testing + +@testable import TableProModels + +struct SavedQueryCodingTests { + private let created = Date(timeIntervalSince1970: 1_000) + private let updated = Date(timeIntervalSince1970: 2_000) + + private func decode(_ type: Value.Type, _ json: String) throws -> Value { + try JSONDecoder().decode(type, from: Data(json.utf8)) + } + + @Test("A saved query keeps every field through a save and a load") + func queryRoundTrips() throws { + let query = SavedQuery( + name: "Daily users", + query: "SELECT 1", + keyword: "dau", + folderId: UUID(), + connectionId: UUID(), + sortOrder: 3, + createdAt: created, + updatedAt: updated + ) + + let decoded = try JSONDecoder().decode(SavedQuery.self, from: JSONEncoder().encode(query)) + + #expect(decoded == query) + } + + @Test("A saved query file without the optional keys loads with the defaults") + func queryOptionalsDefault() throws { + let json = """ + {"id":"6F9619FF-8B86-D011-B42D-00C04FC964FF","name":"Daily","query":"SELECT 1","createdAt":1000} + """ + + let decoded = try decode(SavedQuery.self, json) + + #expect(decoded.keyword == nil) + #expect(decoded.folderId == nil) + #expect(decoded.connectionId == nil) + #expect(decoded.sortOrder == 0) + #expect(decoded.updatedAt == decoded.createdAt) + } + + @Test("A saved query without a required key does not load", arguments: ["id", "name", "query", "createdAt"]) + func queryRequiredKeys(_ missing: String) throws { + var object: [String: Any] = [ + "id": "6F9619FF-8B86-D011-B42D-00C04FC964FF", + "name": "Daily", + "query": "SELECT 1", + "createdAt": 1_000 + ] + object.removeValue(forKey: missing) + let data = try JSONSerialization.data(withJSONObject: object) + + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(SavedQuery.self, from: data) + } + } + + @Test("A new saved query is unfiled, global and created and updated at one moment") + func queryInitDefaults() { + let query = SavedQuery(name: "Daily", query: "SELECT 1") + + #expect(query.keyword == nil) + #expect(query.folderId == nil) + #expect(query.connectionId == nil) + #expect(query.sortOrder == 0) + #expect(query.createdAt == query.updatedAt) + } + + @Test("A folder keeps every field through a save and a load") + func folderRoundTrips() throws { + let folder = SavedQueryFolder( + name: "Reports", + parentId: UUID(), + connectionId: UUID(), + sortOrder: 2, + createdAt: created, + updatedAt: updated + ) + + let decoded = try JSONDecoder().decode(SavedQueryFolder.self, from: JSONEncoder().encode(folder)) + + #expect(decoded == folder) + } + + @Test("A folder file without the optional keys loads with the defaults") + func folderOptionalsDefault() throws { + let json = """ + {"id":"7A9619FF-8B86-D011-B42D-00C04FC964FF","name":"Reports","createdAt":1000} + """ + + let decoded = try decode(SavedQueryFolder.self, json) + + #expect(decoded.parentId == nil) + #expect(decoded.connectionId == nil) + #expect(decoded.sortOrder == 0) + #expect(decoded.updatedAt == decoded.createdAt) + } + + @Test("A folder without a required key does not load", arguments: ["id", "name", "createdAt"]) + func folderRequiredKeys(_ missing: String) throws { + var object: [String: Any] = [ + "id": "7A9619FF-8B86-D011-B42D-00C04FC964FF", + "name": "Reports", + "createdAt": 1_000 + ] + object.removeValue(forKey: missing) + let data = try JSONSerialization.data(withJSONObject: object) + + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(SavedQueryFolder.self, from: data) + } + } + + @Test("A new folder sits at the root, global, created and updated at one moment") + func folderInitDefaults() { + let folder = SavedQueryFolder(name: "Reports") + + #expect(folder.parentId == nil) + #expect(folder.connectionId == nil) + #expect(folder.sortOrder == 0) + #expect(folder.createdAt == folder.updatedAt) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncFieldParityTests.swift b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncFieldParityTests.swift index e0c69f26a0..2341927756 100644 --- a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncFieldParityTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncFieldParityTests.swift @@ -2,16 +2,7 @@ import Foundation @testable import TableProSyncTransport import Testing -/// macOS and iOS map a synced record with two separate `SyncRecordMapper` implementations, because -/// the two platforms cannot share one `DatabaseConnection`: the macOS `DatabaseType` resolves -/// through the plugin registry and is open-ended by design, while iOS carries a fixed list. So the -/// wire contract is the only thing holding them together, and nothing checked that both ends -/// actually use it. -/// -/// A connection's colour reached users that way: macOS wrote `ConnectionColor`'s name to `color` -/// and iOS wrote hex to `colorTag`, each read only its own field, and a colour set on one device -/// was invisible on the other. Every asymmetry left is listed below, so a new one fails here -/// instead of shipping. +// The two apps map records in separate files, so a field only one side writes or reads fails here instead of shipping. @Suite("Sync field parity between the two mappers") struct SyncFieldParityTests { private static let repositoryRoot = URL(fileURLWithPath: #filePath) @@ -24,30 +15,27 @@ struct SyncFieldParityTests { private static let macMapper = "TablePro/Core/Sync/SyncRecordMapper.swift" private static let iosMapper = "Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift" - /// Record types both apps sync. Every field of these has to survive a trip in either direction. + // Every field of these has to survive a trip in either direction. private static let sharedRecordTypes: [String: [String]] = [ "ConnectionSyncField": ConnectionSyncField.allCases.map(\.rawValue), "ConnectionGroupSyncField": ConnectionGroupSyncField.allCases.map(\.rawValue), "ConnectionTagSyncField": ConnectionTagSyncField.allCases.map(\.rawValue), + "SQLFavoriteSyncField": SQLFavoriteSyncField.allCases.map(\.rawValue), + "SQLFavoriteFolderSyncField": SQLFavoriteFolderSyncField.allCases.map(\.rawValue), ] - /// Record types only the Mac syncs, because the iPhone app has no feature that owns them. - /// Listed rather than ignored so half-adding one to iOS fails instead of going unnoticed. + // The iPhone app has no feature that owns these. Listed so half-adding one to iOS fails. private static let macOnlyRecordTypes: Set = [ "AppSettingsSyncField", "FavoriteTableSyncField", "FavoriteDatabaseSyncField", - "SQLFavoriteSyncField", - "SQLFavoriteFolderSyncField", "SSHProfileSyncField", "CredentialProfileSyncField", "TableFolderSyncField", "TableFolderItemSyncField", ] - /// Connection fields only one platform has a model property for. Each is a feature the other - /// app does not have, and `CKModifyRecordsOperation.savePolicy = .changedKeys` means the - /// platform that does not write one also cannot erase it. + // A feature the other app lacks; under `.changedKeys` the app that never writes the field cannot erase it either. private static let connectionMacOnly: Set = [ "aiPolicy", "aiRules", "aiAlwaysAllowedTools", "redisDatabase", "startupCommands", "sshProfileId", @@ -57,10 +45,9 @@ struct SyncFieldParityTests { "sshEnabled", "sslEnabled", ] - /// Written by whichever mapper owns the value and never read back by it. private static let writeOnly: Set = ["modifiedAtLocal", "schemaVersion"] - /// Read for backwards compatibility and no longer written by anyone. + // Still read from records older builds wrote. private static let legacyReadOnly: Set = ["colorTag"] private struct MapperScan { @@ -73,9 +60,7 @@ struct SyncFieldParityTests { func read(_ recordType: String) -> Set { read[recordType] ?? [] } } - /// Scope follows either the accessor a function builds for itself or the one it is handed as a - /// parameter. Reading only the first missed `color`, which a helper reads from an argument. The - /// accessor may also take an `absentValues:` argument after the type. + // A helper can be handed the accessor as a parameter, so its `SyncRecordFields<...>` type sets the scope too. private static let scopePattern = try! NSRegularExpression( pattern: #"\.fields\((\w+)\.self[,)]|SyncRecordFields<(\w+)>"# ) diff --git a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSavedQueryTests.swift b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSavedQueryTests.swift new file mode 100644 index 0000000000..c0b45064a5 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSavedQueryTests.swift @@ -0,0 +1,172 @@ +import CloudKit +import Foundation +import Testing + +@testable import TableProModels +@testable import TableProSync +@testable import TableProSyncTransport + +struct SyncRecordMapperSavedQueryTests { + private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + private let created = Date(timeIntervalSince1970: 1_000) + private let updated = Date(timeIntervalSince1970: 2_000) + + private func filedQuery() -> SavedQuery { + SavedQuery( + name: "Daily users", + query: "SELECT count(*) FROM users", + keyword: "dau", + folderId: UUID(), + connectionId: UUID(), + sortOrder: 4, + createdAt: created, + updatedAt: updated + ) + } + + private func nestedFolder() -> SavedQueryFolder { + SavedQueryFolder( + name: "Reports", + parentId: UUID(), + connectionId: UUID(), + sortOrder: 2, + createdAt: created, + updatedAt: updated + ) + } + + private func rawRecord(_ type: SyncRecordType, values: [String: CKRecordValue]) -> CKRecord { + let id = SyncRecordMapper.recordID(type: type, id: UUID().uuidString, in: zoneID) + let record = CKRecord(recordType: type.rawValue, recordID: id) + for (key, value) in values { + record[key] = value + } + return record + } + + @Test("A saved query survives a trip through a record") + func queryRoundTrips() throws { + let query = filedQuery() + + let decoded = try #require(SyncRecordMapper.toSavedQuery(SyncRecordMapper.toRecord(query, zoneID: zoneID))) + + #expect(decoded == query) + } + + @Test("A saved query record carries the Mac's record type and name") + func queryRecordIdentity() { + let query = filedQuery() + + let record = SyncRecordMapper.toRecord(query, zoneID: zoneID) + + #expect(record.recordType == "SQLFavorite") + #expect(record.recordID.recordName == "Favorite_\(query.id.uuidString)") + #expect(Set(record.allKeys()).isSubset(of: SQLFavoriteSyncField.declaredKeys)) + } + + @Test("A global, unfiled query without a keyword names those keys so the server clears them") + func globalUnfiledQueryClearsItsKeys() { + let query = SavedQuery(name: "Everywhere", query: "SELECT 1", createdAt: created) + + let record = SyncRecordMapper.toRecord(query, zoneID: zoneID) + + for key in ["connectionId", "folderId", "keyword"] { + #expect(record.changedKeys().contains(key)) + #expect(!record.allKeys().contains(key)) + } + } + + @Test("A pulled query keeps the creation date the keyword contest is decided by") + func createdAtIsKept() throws { + let record = SyncRecordMapper.toRecord(filedQuery(), zoneID: zoneID) + + let decoded = try #require(SyncRecordMapper.toSavedQuery(record)) + + #expect(decoded.createdAt == created) + #expect(decoded.updatedAt == updated) + } + + @Test("A record with only the required fields decodes with the defaults") + func minimalQueryRecordDecodes() throws { + let id = UUID() + let record = rawRecord(.favorite, values: [ + "favoriteId": id.uuidString as CKRecordValue, + "name": "Bare" as CKRecordValue, + "query": "SELECT 1" as CKRecordValue + ]) + + let decoded = try #require(SyncRecordMapper.toSavedQuery(record)) + + #expect(decoded.id == id) + #expect(decoded.keyword == nil) + #expect(decoded.folderId == nil) + #expect(decoded.connectionId == nil) + #expect(decoded.sortOrder == 0) + } + + @Test("A query record missing a required field is not decoded", arguments: ["favoriteId", "name", "query"]) + func queryMissingRequiredFieldIsNil(_ missing: String) { + var values: [String: CKRecordValue] = [ + "favoriteId": UUID().uuidString as CKRecordValue, + "name": "Daily" as CKRecordValue, + "query": "SELECT 1" as CKRecordValue + ] + values.removeValue(forKey: missing) + + #expect(SyncRecordMapper.toSavedQuery(rawRecord(.favorite, values: values)) == nil) + } + + @Test("A query record whose id is not a UUID is not decoded") + func queryWithBadIdIsNil() { + let record = rawRecord(.favorite, values: [ + "favoriteId": "not-a-uuid" as CKRecordValue, + "name": "Daily" as CKRecordValue, + "query": "SELECT 1" as CKRecordValue + ]) + + #expect(SyncRecordMapper.toSavedQuery(record) == nil) + } + + @Test("A saved query folder survives a trip through a record") + func folderRoundTrips() throws { + let folder = nestedFolder() + + let decoded = try #require(SyncRecordMapper.toSavedQueryFolder(SyncRecordMapper.toRecord(folder, zoneID: zoneID))) + + #expect(decoded == folder) + } + + @Test("A folder record carries the Mac's record type and name") + func folderRecordIdentity() { + let folder = nestedFolder() + + let record = SyncRecordMapper.toRecord(folder, zoneID: zoneID) + + #expect(record.recordType == "SQLFavoriteFolder") + #expect(record.recordID.recordName == "FavoriteFolder_\(folder.id.uuidString)") + #expect(Set(record.allKeys()).isSubset(of: SQLFavoriteFolderSyncField.declaredKeys)) + } + + @Test("A global top-level folder names its parent and connection so the server clears them") + func globalRootFolderClearsItsKeys() { + let folder = SavedQueryFolder(name: "Shared", createdAt: created) + + let record = SyncRecordMapper.toRecord(folder, zoneID: zoneID) + + for key in ["connectionId", "parentId"] { + #expect(record.changedKeys().contains(key)) + #expect(!record.allKeys().contains(key)) + } + } + + @Test("A folder record missing a required field is not decoded", arguments: ["folderId", "name"]) + func folderMissingRequiredFieldIsNil(_ missing: String) { + var values: [String: CKRecordValue] = [ + "folderId": UUID().uuidString as CKRecordValue, + "name": "Reports" as CKRecordValue + ] + values.removeValue(forKey: missing) + + #expect(SyncRecordMapper.toSavedQueryFolder(rawRecord(.favoriteFolder, values: values)) == nil) + } +} diff --git a/apple/TablePro/Core/ConnectionLibrary/ConnectionLibraryConformances.swift b/apple/TablePro/Core/ConnectionLibrary/ConnectionLibraryConformances.swift index 40d4443af6..2f3b0c7c64 100644 --- a/apple/TablePro/Core/ConnectionLibrary/ConnectionLibraryConformances.swift +++ b/apple/TablePro/Core/ConnectionLibrary/ConnectionLibraryConformances.swift @@ -1,8 +1,3 @@ -// -// ConnectionLibraryConformances.swift -// TablePro -// - import Foundation import TableProConnectionLibrary import TableProImport @@ -15,6 +10,8 @@ extension ConnectionGroup: LibraryGroupRepresentable {} extension ConnectionTag: LibraryTagRepresentable {} +extension SQLFavorite: SavedQueryRepresentable {} + internal extension LinkedConnection { var libraryEntry: LibraryExternalEntry { LibraryExternalEntry( diff --git a/apple/TablePro/Core/Storage/RemoteFavoriteKeywordResolver.swift b/apple/TablePro/Core/Storage/RemoteFavoriteKeywordResolver.swift index 8bee141b4d..16f0e02098 100644 --- a/apple/TablePro/Core/Storage/RemoteFavoriteKeywordResolver.swift +++ b/apple/TablePro/Core/Storage/RemoteFavoriteKeywordResolver.swift @@ -1,9 +1,5 @@ -// -// RemoteFavoriteKeywordResolver.swift -// TablePro -// - import Foundation +import TableProConnectionLibrary internal struct RemoteFavoriteKeywordResolution: Equatable { let upserts: [SQLFavorite] @@ -16,57 +12,15 @@ internal struct RemoteFavoriteKeywordResolution: Equatable { } } +// The iOS app resolves with the same shared rule, so a contested keyword settles the same way on every device. internal enum RemoteFavoriteKeywordResolver { static func resolve(incoming: [SQLFavorite], local: [SQLFavorite]) -> RemoteFavoriteKeywordResolution { - let upserts = latestVersions(of: incoming) - let incomingIds = Set(upserts.map(\.id)) - let bystanders = local.filter { !incomingIds.contains($0.id) } - let losingIds = keywordLosers(among: bystanders + upserts) - + let shared = SavedQueryKeywordResolver.resolve(incoming: incoming, local: local) return RemoteFavoriteKeywordResolution( - upserts: upserts.map { losingIds.contains($0.id) ? releasingKeyword($0) : $0 }, - vacatedLocalIds: local.map(\.id).filter { incomingIds.contains($0) || losingIds.contains($0) }, - releasedLocalIds: bystanders.map(\.id).filter { losingIds.contains($0) }, - releasedIncomingIds: upserts.map(\.id).filter { losingIds.contains($0) } + upserts: shared.upserts, + vacatedLocalIds: shared.vacatedLocalIds, + releasedLocalIds: shared.releasedLocalIds, + releasedIncomingIds: shared.releasedIncomingIds ) } - - static func holdsKeywordFirst(_ lhs: SQLFavorite, _ rhs: SQLFavorite) -> Bool { - guard lhs.createdAt == rhs.createdAt else { return lhs.createdAt < rhs.createdAt } - return lhs.id.uuidString < rhs.id.uuidString - } - - private struct KeywordSlot: Hashable { - let keyword: String - let connectionId: UUID - } - - private static func slot(of favorite: SQLFavorite) -> KeywordSlot? { - guard let keyword = favorite.keyword, let connectionId = favorite.connectionId else { return nil } - return KeywordSlot(keyword: keyword, connectionId: connectionId) - } - - private static func keywordLosers(among contenders: [SQLFavorite]) -> Set { - var claimants: [KeywordSlot: [SQLFavorite]] = [:] - for favorite in contenders { - guard let slot = slot(of: favorite) else { continue } - claimants[slot, default: []].append(favorite) - } - return Set(claimants.values.flatMap { $0.sorted(by: holdsKeywordFirst).dropFirst().map(\.id) }) - } - - private static func latestVersions(of favorites: [SQLFavorite]) -> [SQLFavorite] { - var latestById: [UUID: SQLFavorite] = [:] - var arrivalOrder: [UUID] = [] - for favorite in favorites where latestById.updateValue(favorite, forKey: favorite.id) == nil { - arrivalOrder.append(favorite.id) - } - return arrivalOrder.compactMap { latestById[$0] } - } - - private static func releasingKeyword(_ favorite: SQLFavorite) -> SQLFavorite { - var released = favorite - released.keyword = nil - return released - } } diff --git a/apple/TablePro/Core/Storage/SQLFavoriteStorage+Import.swift b/apple/TablePro/Core/Storage/SQLFavoriteStorage+Import.swift index 1523719694..b2e70f6232 100644 --- a/apple/TablePro/Core/Storage/SQLFavoriteStorage+Import.swift +++ b/apple/TablePro/Core/Storage/SQLFavoriteStorage+Import.swift @@ -1,15 +1,9 @@ -// -// SQLFavoriteStorage+Import.swift -// TablePro -// - import Foundation import os import TableProImport internal extension SQLFavoriteStorage { - /// Decides against live rows inside the transaction, so a query or folder a sync pull wrote - /// after the preview is matched instead of duplicated. Any failure rolls the whole import back. + // Decides against live rows inside the transaction, so a query a pull wrote after the preview is matched, not duplicated. func importSavedQueries(_ queries: [PlannedQuery], now: Date = Date()) -> SavedQueryImportWrite? { guard !queries.isEmpty else { return ImportTally().write } return inTransaction { () -> SavedQueryImportWrite? in @@ -22,27 +16,11 @@ internal extension SQLFavoriteStorage { } } - /// One actor call, so the folders match the favorites they were read with. + // One actor call, so the folders match the favorites they were read with. func readAllFavoritesAndFolders() -> (favorites: [SQLFavorite], folders: [SQLFavoriteFolder])? { guard let favorites = readAllFavorites(), let folders = readAllFolders() else { return nil } return (favorites, folders) } - - /// Cuts the chain where a folder's parent cannot hold it, then trims from the leaf until the - /// last folder can hold the query. - static func holdableFolderPath(_ path: [PathComponent], for scope: UUID?) -> [PathComponent] { - var kept: [PathComponent] = [] - for component in path { - guard !component.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - SavedQueryScopeRule.folder(kept.last?.scope, canHold: component.scope) - else { break } - kept.append(component) - } - while let leaf = kept.last, !SavedQueryScopeRule.folder(leaf.scope, canHold: scope) { - kept.removeLast() - } - return kept - } } private extension SQLFavoriteStorage { @@ -83,14 +61,14 @@ private extension SQLFavoriteStorage { var tally = ImportTally() for query in queries { - let name = Self.importedName(query) + let name = SavedQueryImportPlacement.importedName(query) switch ledger.verdict(name: name, sql: query.sql, keyword: query.keyword, scope: query.connectionId) { case .alreadySaved: tally.alreadySaved += 1 case .tooLarge: tally.tooLarge += 1 case .insert(let keyword, let dropped, _): - let path = Self.holdableFolderPath(query.folderPath, for: query.connectionId) + let path = SavedQueryImportPlacement.holdableFolderPath(query.folderPath, for: query.connectionId) let resolved = PathTreeResolver.resolve([path], existing: folderNodes) for node in resolved.created { let folder = SQLFavoriteFolder( @@ -127,9 +105,4 @@ private extension SQLFavoriteStorage { } return tally.write } - - static func importedName(_ query: PlannedQuery) -> String { - let trimmed = query.name.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? SavedQueryName.derived(from: query.sql) : query.name - } } diff --git a/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift b/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift index 1d32e3e4f1..1ed7b9e32a 100644 --- a/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift +++ b/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift @@ -79,6 +79,15 @@ extension SyncCoordinator { Set(SyncRecordType.verifiedInProduction.map(\.rawValue)).union([Self.themeContentKind]) } + // Only what this Mac applies: a pull skips a category that is off and still moves the token past it. + static func readableContent(for settings: SyncSettings) -> Set { + var content = Set(SyncRecordType.verifiedInProduction.filter { settings.syncs($0) }.map(\.rawValue)) + if settings.syncs(.settings) { + content.insert(Self.themeContentKind) + } + return content + } + func applyRemoteThemeDeletions(_ settingsRecordNames: Set) -> Bool { let prefix = SyncRecordType.settings.recordNamePrefix + ThemeCatalog.syncCategoryPrefix let ids = Set(settingsRecordNames.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) }) diff --git a/apple/TablePro/Core/Sync/SyncCoordinator.swift b/apple/TablePro/Core/Sync/SyncCoordinator.swift index 95d58a89ca..b9b92361f9 100644 --- a/apple/TablePro/Core/Sync/SyncCoordinator.swift +++ b/apple/TablePro/Core/Sync/SyncCoordinator.swift @@ -659,22 +659,34 @@ final class SyncCoordinator: ObservableObject { SyncFailure(error) == .tokenExpired } + // A delta never resends a record the token passed, and a category toggle starts no run, so every pull checks. + private func adoptReadableContent(for settings: SyncSettings) { + guard metadataStorage.adoptReadableRecordTypes(Self.readableContent(for: settings)) else { return } + metadataStorage.saveToken(nil) + Self.logger.info("Reading record types the last pull skipped: cleared sync token for full fetch") + } + /// Reports a failed download instead of passing over it: a run whose pull failed did not bring /// this Mac up to date, and must not say Synced or move Last Synced. private func performPull(echoGuard: SyncEchoGuard?) async -> SyncStepFailure? { + // Applied with the categories recorded here, so one turned off during the download is still read once. + let settings = services.appSettingsStorage.loadSync() + adoptReadableContent(for: settings) let token = metadataStorage.loadToken() let tokenStatus = token == nil ? "nil (full fetch)" : "present (delta)" Self.logger.info("Pull starting, token: \(tokenStatus)") do { let result = try await transport.pull(since: token) - return await applyPull(result, isFullFetch: token == nil, echoGuard: echoGuard) ? nil : .pullNotSaved + return await applyPull(result, isFullFetch: token == nil, settings: settings, echoGuard: echoGuard) + ? nil : .pullNotSaved } catch let error where Self.isTokenExpired(error) { Self.logger.warning("Change token expired, clearing and retrying with full fetch") metadataStorage.saveToken(nil) do { let result = try await transport.pull(since: nil) - return await applyPull(result, isFullFetch: true, echoGuard: echoGuard) ? nil : .pullNotSaved + return await applyPull(result, isFullFetch: true, settings: settings, echoGuard: echoGuard) + ? nil : .pullNotSaved } catch { Self.logger.error("Full fetch after token expiry failed: \(error.localizedDescription)") return SyncStepFailure(error) @@ -685,8 +697,13 @@ final class SyncCoordinator: ObservableObject { } } - private func applyPull(_ result: PullResult, isFullFetch: Bool, echoGuard: SyncEchoGuard?) async -> Bool { - guard await applyPullResult(result, echoGuard: echoGuard) else { return false } + private func applyPull( + _ result: PullResult, + isFullFetch: Bool, + settings: SyncSettings, + echoGuard: SyncEchoGuard? + ) async -> Bool { + guard await applyPullResult(result, settings: settings, echoGuard: echoGuard) else { return false } if isFullFetch { seedLocalThemesIfPending(after: result) } @@ -694,8 +711,12 @@ final class SyncCoordinator: ObservableObject { } @discardableResult - internal func applyPullResult(_ result: PullResult, echoGuard: SyncEchoGuard? = nil) async -> Bool { - let settings = services.appSettingsStorage.loadSync() + internal func applyPullResult( + _ result: PullResult, + settings: SyncSettings? = nil, + echoGuard: SyncEchoGuard? = nil + ) async -> Bool { + let settings = settings ?? services.appSettingsStorage.loadSync() let deletedRecordIDs = result.deletedRecordIDs.filter { recordID in echoGuard?.withholdsDeletion(recordID, tracker: changeTracker) != true } @@ -1105,17 +1126,7 @@ final class SyncCoordinator: ObservableObject { Self.logger.info("No local connections: cleared sync token for full fetch") } - /// A pull returns only what changed since the token, so records of a type an earlier build - /// did not sync were passed over for good. The first run of a build that syncs more types - /// fetches the zone again, which is how table folders made on an upgraded Mac reach one that - /// upgrades later. Counted over the types verified in Production, because an unverified type - /// has no records on the server to fetch, plus the content kinds an older build skipped - /// inside a type it read (`readableContent`). Checked on every turn to on, because a sync - /// turned on mid-session after an upgrade would otherwise keep a token from before it. - if metadataStorage.adoptReadableRecordTypes(Self.readableContent) { - metadataStorage.saveToken(nil) - Self.logger.info("This build reads record types the last one did not: cleared sync token for full fetch") - } + adoptReadableContent(for: services.appSettingsStorage.loadSync()) scheduleThemeSeedIfNeeded() } diff --git a/apple/TableProMobile/TableProMobile/AppState.swift b/apple/TableProMobile/TableProMobile/AppState.swift index 084bb272ba..cb300b8b5b 100644 --- a/apple/TableProMobile/TableProMobile/AppState.swift +++ b/apple/TableProMobile/TableProMobile/AppState.swift @@ -43,6 +43,8 @@ final class AppState { let secureStore: any SecureStore let localDatabaseFiles: LocalDatabaseFileLocator let queryHistory: QueryHistoryStorage + // Outside `loadStatus`, so a saved queries file that fails to load never locks the connections. + let savedQueries: SavedQueryLibrary private let sampleInstaller: SampleDatabaseInstaller private let libraryPublisher: ConnectionLibraryPublisher @@ -70,6 +72,7 @@ final class AppState { syncCoordinator = injectedSyncCoordinator ?? IOSSyncCoordinator() storage = ConnectionPersistence(directory: libraryDirectory) queryHistory = QueryHistoryStorage(directory: libraryDirectory) + savedQueries = SavedQueryLibrary(directory: libraryDirectory, syncCoordinator: syncCoordinator) groupStorage = GroupPersistence(directory: libraryDirectory) tagStorage = TagPersistence(directory: libraryDirectory) let driverFactory: any DriverFactory = injectedDriverFactory @@ -95,6 +98,9 @@ final class AppState { self.sessionRedialer = SessionRedialer(connectionManager: connectionManager) self.backgroundRelease = BackgroundReleaseCoordinator(connectionManager: connectionManager) loadPersistedData() + savedQueries.syncsOwner = { [weak self] id in + self?.syncsConnection(id) ?? true + } guard !TestRuntime.isActive else { return } @@ -105,26 +111,39 @@ final class AppState { } } + connectSync() + } + + // Separate from init, which skips it under tests, so a test can run the wiring the app runs. + func connectSync() { syncCoordinator.onConnectionsChanged = { [weak self] merged in - self?.applySyncedConnections(merged) + self?.applySyncedConnections(merged) ?? false } syncCoordinator.onGroupsChanged = { [weak self] merged in - guard let self else { return } - guard merged != self.groups else { return } - self.persist(groups: merged) + guard let self else { return false } + guard merged != self.groups else { return true } + return self.persist(groups: merged, onlyIfSaved: true) } syncCoordinator.onTagsChanged = { [weak self] merged in - guard let self else { return } - guard merged != self.tags else { return } - self.persist(tags: merged) + guard let self else { return false } + guard merged != self.tags else { return true } + return self.persist(tags: merged, onlyIfSaved: true) } syncCoordinator.getCurrentState = { [weak self] in guard let self, self.loadStatus == .ready else { return nil } return (self.connections, self.groups, self.tags) } + + syncCoordinator.getSavedQueries = { [weak self] in + self?.savedQueries.syncContents + } + + syncCoordinator.onSavedQueriesChanged = { [weak self] merged in + self?.savedQueries.applySynced(merged) ?? false + } } // MARK: - Load / Retry @@ -134,6 +153,7 @@ final class AppState { } func retryLoadIfFailed() { + savedQueries.retryLoadIfFailed() guard loadStatus == .failed else { return } Self.logger.info("Retrying persistence load after previous failure") loadPersistedData() @@ -154,13 +174,16 @@ final class AppState { libraryPublisher.publish(connections) } - func applySyncedConnections(_ merged: [DatabaseConnection]) { - guard !refuseWriteIfNotReady() else { return } - guard merged != connections else { return } + @discardableResult + func applySyncedConnections(_ merged: [DatabaseConnection]) -> Bool { + guard !refuseWriteIfNotReady() else { return false } + guard merged != connections else { return true } let removedIds = Set(connections.map(\.id)).subtracting(merged.map(\.id)) - persist(connections: merged) + guard persist(connections: merged, onlyIfSaved: true) else { return false } localState.purge(removedIds) + savedQueries.removeRecords(ofConnections: removedIds, tombstoning: false) publishLibrary() + return true } private func syncsConnection(_ id: UUID) -> Bool { @@ -211,37 +234,51 @@ final class AppState { // MARK: - Persistence Bridges - private func persist(connections: [DatabaseConnection]) { - connectionsState = .loaded(connections) - pastedSSHKeysAwaitingKeychain = PastedSSHKeyMigration.keysStillHeld( - pastedSSHKeysAwaitingKeychain, - by: connections - ) + // A pulled change shown but not saved would make the next equal merge report it saved and move the token past it. + @discardableResult + private func persist(connections: [DatabaseConnection], onlyIfSaved: Bool = false) -> Bool { + let heldKeys = PastedSSHKeyMigration.keysStillHeld(pastedSSHKeysAwaitingKeychain, by: connections) + var saved = true do { - try storage.save(connections, keepingPastedSSHKeys: pastedSSHKeysAwaitingKeychain) + try storage.save(connections, keepingPastedSSHKeys: heldKeys) } catch { + saved = false Self.logger.error("Failed to save connections: \(error.localizedDescription, privacy: .public)") } + guard saved || !onlyIfSaved else { return false } + connectionsState = .loaded(connections) + pastedSSHKeysAwaitingKeychain = heldKeys pruneLibraryPreferences() + return saved } - private func persist(groups: [ConnectionGroup]) { - groupsState = .loaded(groups) + @discardableResult + private func persist(groups: [ConnectionGroup], onlyIfSaved: Bool = false) -> Bool { + var saved = true do { try groupStorage.save(groups) } catch { + saved = false Self.logger.error("Failed to save groups: \(error.localizedDescription, privacy: .public)") } + guard saved || !onlyIfSaved else { return false } + groupsState = .loaded(groups) pruneLibraryPreferences() + return saved } - private func persist(tags: [ConnectionTag]) { - tagsState = .loaded(tags) + @discardableResult + private func persist(tags: [ConnectionTag], onlyIfSaved: Bool = false) -> Bool { + var saved = true do { try tagStorage.save(tags) } catch { + saved = false Self.logger.error("Failed to save tags: \(error.localizedDescription, privacy: .public)") } + guard saved || !onlyIfSaved else { return false } + tagsState = .loaded(tags) + return saved } private func pruneLibraryPreferences() { @@ -336,6 +373,9 @@ final class AppState { guard !removed.isEmpty else { return } persist(connections: connections.filter { !ids.contains($0.id) }) localState.purge(Set(removed.map(\.id))) + let syncedIds = Set(removed.filter(\.participatesInSync).map(\.id)) + savedQueries.removeRecords(ofConnections: syncedIds, tombstoning: true) + savedQueries.removeRecords(ofConnections: Set(removed.map(\.id)).subtracting(syncedIds), tombstoning: false) publishLibrary() for connection in removed where connection.participatesInSync { syncCoordinator.markDeleted(connection.id) diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionLibraryConformances.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionLibraryConformances.swift index 862d440b98..5dfc3b4a8f 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionLibraryConformances.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionLibraryConformances.swift @@ -9,3 +9,7 @@ nonisolated extension DatabaseConnection: LibraryConnectionRepresentable { nonisolated extension ConnectionGroup: LibraryGroupRepresentable {} nonisolated extension ConnectionTag: LibraryTagRepresentable {} + +nonisolated extension SavedQuery: SavedQueryRepresentable {} + +nonisolated extension SavedQueryFolder: SavedQueryFolderRepresentable {} diff --git a/apple/TableProMobile/TableProMobile/Helpers/MobileImportOutcomeMessage.swift b/apple/TableProMobile/TableProMobile/Helpers/MobileImportOutcomeMessage.swift new file mode 100644 index 0000000000..718a2adb70 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/MobileImportOutcomeMessage.swift @@ -0,0 +1,64 @@ +import Foundation +import TableProImport + +nonisolated struct MobileImportOutcomeMessage: Equatable, Sendable { + let title: String + let message: String + + init(_ outcome: ImportOutcome) { + let text = Self.text(for: outcome) + title = text.title + message = text.message + } + + private static func text(for outcome: ImportOutcome) -> (title: String, message: String) { + let connections = outcome.connectionsAdded + outcome.connectionsReplaced + let failedTitle = String(localized: "Import Failed") + switch outcome.failure { + case .libraryUnreadable: + return (failedTitle, String(localized: "TablePro could not read your connection library, so nothing was imported.")) + case .connectionsNotSaved: + return (failedTitle, String(localized: "The connections could not be saved.")) + case .savedQueriesNotSaved: + let queriesFailed = String(localized: "The saved queries could not be saved.") + guard let imported = connectionSentence(connections) else { + return (failedTitle, queriesFailed) + } + return (String(localized: "Import Incomplete"), "\(imported) \(queriesFailed)") + case nil: + break + } + + let imported = [connectionSentence(connections), addedSentence(outcome.savedQueriesAdded)].compactMap { $0 } + guard !imported.isEmpty else { + return ( + String(localized: "Nothing Imported"), + notImportedSentence(outcome.savedQueriesNotImported) + ?? String(localized: "Everything selected was already in your library.") + ) + } + let sentences = imported + [notImportedSentence(outcome.savedQueriesNotImported)].compactMap { $0 } + return (String(localized: "Import Complete"), sentences.joined(separator: " ")) + } + + private static func connectionSentence(_ count: Int) -> String? { + guard count > 0 else { return nil } + return count == 1 + ? String(localized: "1 connection was imported.") + : String(format: String(localized: "%d connections were imported."), count) + } + + private static func addedSentence(_ count: Int) -> String? { + guard count > 0 else { return nil } + return count == 1 + ? String(localized: "1 saved query was added.") + : String(format: String(localized: "%d saved queries were added."), count) + } + + private static func notImportedSentence(_ count: Int) -> String? { + guard count > 0 else { return nil } + return count == 1 + ? String(localized: "1 saved query was not imported.") + : String(format: String(localized: "%d saved queries were not imported."), count) + } +} diff --git a/apple/TableProMobile/TableProMobile/Helpers/SavedQueryDraft.swift b/apple/TableProMobile/TableProMobile/Helpers/SavedQueryDraft.swift new file mode 100644 index 0000000000..13ed2f6192 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/SavedQueryDraft.swift @@ -0,0 +1,287 @@ +import Foundation +import TableProImport +import TableProModels + +nonisolated enum SavedQueryKeywordCheck: Equatable, Sendable { + case valid + case containsSpaces + case inUse + case shadowsSQLKeyword(String) + + var blocksSave: Bool { + switch self { + case .containsSpaces, .inUse: + true + case .valid, .shadowsSQLKeyword: + false + } + } + + var message: String? { + switch self { + case .valid: + nil + case .containsSpaces: + String(localized: "Keyword cannot contain spaces") + case .inUse: + String(localized: "This keyword is already in use") + case .shadowsSQLKeyword(let keyword): + String(format: String(localized: "Shadows the SQL keyword '%@'"), keyword.uppercased()) + } + } +} + +nonisolated struct SavedQueryValidation: Equatable, Sendable { + let keywordCheck: SavedQueryKeywordCheck + let exceedsSizeLimit: Bool +} + +nonisolated struct SavedQueryDraft: Equatable, Sendable { + var name: String + var keyword: String + var folderId: UUID? + var isGlobal: Bool + + init(name: String, keyword: String = "", folderId: UUID? = nil, isGlobal: Bool = false) { + self.name = name + self.keyword = keyword + self.folderId = folderId + self.isGlobal = isGlobal + } + + init(query: SavedQuery) { + self.init( + name: query.name, + keyword: query.keyword ?? "", + folderId: query.folderId, + isGlobal: query.connectionId == nil + ) + } + + init(saving sql: String) { + self.init(name: SavedQueryName.derived(from: sql)) + } + + static func savableSQL(from editorText: String) -> String? { + let trimmed = editorText.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + // Opened text leaves the caret at its end, where Run finds no statement past a trailing newline. + static func editorText(opening sql: String) -> String { + sql.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static var sizeLimitMessage: String { + String( + format: String(localized: "Saved queries are limited to %@ so they fit in iCloud."), + ByteCountFormatter.string(fromByteCount: Int64(SavedQuerySize.maximumSyncableByteCount), countStyle: .file) + ) + } + + static func hidesFolders(_ folders: [SavedQueryFolder], forScope scope: UUID?) -> Bool { + folders.contains { !SavedQueryScopeRule.folder($0.connectionId, canHold: scope) } + } + + static func acceptedName(_ proposed: String, replacing current: String?) -> String? { + let trimmed = proposed.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, trimmed != current else { return nil } + return trimmed + } + + static func renamedName(_ proposed: String, of query: SavedQuery) -> String? { + guard let name = acceptedName(proposed, replacing: query.name), + SavedQuerySize.byteCount(name: name, sql: query.query, keyword: query.keyword) + <= SavedQuerySize.maximumSyncableByteCount + else { return nil } + return name + } + + // A move never puts a query in a folder narrower than its own scope, or other connections lose it. + static func move(_ query: inout SavedQuery, to folder: SavedQueryFolder?) { + if let folder, !SavedQueryScopeRule.folder(folder.connectionId, canHold: query.connectionId) { return } + query.folderId = folder?.id + } + + var trimmedName: String { + name.trimmingCharacters(in: .whitespaces) + } + + var normalizedKeyword: String? { + SavedQueryKeyword.normalized(keyword) + } + + func scope(in connectionId: UUID) -> UUID? { + isGlobal ? nil : connectionId + } + + func keywordCheck(isAvailable: Bool) -> SavedQueryKeywordCheck { + Self.keywordCheck(normalizedKeyword, isAvailable: isAvailable) + } + + private static func keywordCheck(_ keyword: String?, isAvailable: Bool) -> SavedQueryKeywordCheck { + guard let keyword else { return .valid } + guard SavedQueryKeyword.isValid(keyword) else { return .containsSpaces } + guard isAvailable else { return .inUse } + guard !SavedQueryKeyword.shadowsSQLKeyword(keyword) else { return .shadowsSQLKeyword(keyword) } + return .valid + } + + // Checks the row as it will be saved: an edit lands on the stored row, which a pull may have changed meanwhile. + func validation( + sql: String, + savingOver live: SavedQuery?, + changedSince opening: SavedQueryDraft, + connectionId: UUID, + folders: [SavedQueryFolder], + isAvailable: (_ keyword: String, _ scope: UUID?) -> Bool + ) -> SavedQueryValidation { + var saved = live ?? newQuery(sql: sql, connectionId: connectionId) + if live != nil { + apply(to: &saved, changedSince: opening, connectionId: connectionId, folders: folders) + } + let keyword = saved.keyword + return SavedQueryValidation( + keywordCheck: Self.keywordCheck(keyword, isAvailable: keyword.map { isAvailable($0, saved.connectionId) } ?? true), + exceedsSizeLimit: SavedQuerySize.byteCount(name: saved.name, sql: saved.query, keyword: keyword) + > SavedQuerySize.maximumSyncableByteCount + ) + } + + func canSave(sql: String, validation: SavedQueryValidation) -> Bool { + !trimmedName.isEmpty + && sql.contains { !$0.isWhitespace } + && !validation.exceedsSizeLimit + && !validation.keywordCheck.blocksSave + } + + func resolvingFolder(among folders: [SavedQueryFolder], connectionId: UUID) -> SavedQueryDraft { + guard let folderId, + let folder = folders.first(where: { $0.id == folderId }), + !SavedQueryScopeRule.folder(folder.connectionId, canHold: scope(in: connectionId)) + else { return self } + var resolved = self + resolved.folderId = nil + return resolved + } + + func newQuery(sql: String, connectionId: UUID, now: Date = Date()) -> SavedQuery { + SavedQuery( + name: trimmedName, + query: sql, + keyword: normalizedKeyword, + folderId: folderId, + connectionId: scope(in: connectionId), + createdAt: now, + updatedAt: now + ) + } + + // Only the fields the form changed are written, so a folder or keyword a pull brought in meanwhile stays. + func apply( + to query: inout SavedQuery, + changedSince opening: SavedQueryDraft, + connectionId: UUID, + folders: [SavedQueryFolder] + ) { + if trimmedName != opening.trimmedName { + query.name = trimmedName + } + if normalizedKeyword != opening.normalizedKeyword { + query.keyword = normalizedKeyword + } + if isGlobal != opening.isGlobal { + query.connectionId = scope(in: connectionId) + } + if folderId != opening.folderId { + query.folderId = folderId + } + // A placement the Mac made before the scope rule stays until this form moves or rescopes the query. + guard isGlobal != opening.isGlobal || folderId != opening.folderId, + let heldId = query.folderId, + let folder = folders.first(where: { $0.id == heldId }), + !SavedQueryScopeRule.folder(folder.connectionId, canHold: query.connectionId) + else { return } + query.folderId = nil + } +} + +nonisolated enum SavedQueryWriteFailure: Equatable, Sendable { + case queryDeleted + case folderDeleted + case notSaved + + static func failedQueryWrite(_ id: UUID, in queries: [SavedQuery]) -> SavedQueryWriteFailure { + queries.contains { $0.id == id } ? .notSaved : .queryDeleted + } + + static func failedFolderWrite(_ id: UUID, in folders: [SavedQueryFolder]) -> SavedQueryWriteFailure { + folders.contains { $0.id == id } ? .notSaved : .folderDeleted + } + + var title: String { + switch self { + case .queryDeleted: + String(localized: "Saved Query Deleted") + case .folderDeleted: + String(localized: "Folder Deleted") + case .notSaved: + String(localized: "Change Not Saved") + } + } + + var message: String { + switch self { + case .queryDeleted: + String(localized: "This saved query no longer exists. It may have been removed from another device.") + case .folderDeleted: + String(localized: "This folder no longer exists. It may have been removed from another device.") + case .notSaved: + String(localized: "TablePro could not write your saved queries, so this change was not saved.") + } + } +} + +nonisolated enum SavedQueryPreview { + static let maximumLength = 50 + + // Bounded scan: a saved query can be one 900 KB line, and every row draws its preview. + static func firstLine(of sql: String) -> String? { + let text = sql as NSString + let length = text.length + let content = CharacterSet.whitespacesAndNewlines.inverted + var position = 0 + while position < length { + let start = text.rangeOfCharacter( + from: content, + options: [], + range: NSRange(location: position, length: length - position) + ).location + guard start != NSNotFound else { return nil } + + if text.substring(with: NSRange(location: start, length: min(2, length - start))) == "--" { + let lineBreak = text.rangeOfCharacter( + from: .newlines, + options: [], + range: NSRange(location: start, length: length - start) + ).location + guard lineBreak != NSNotFound else { return nil } + position = lineBreak + 1 + continue + } + + let window = NSRange(location: start, length: min(maximumLength, length - start)) + let lineBreak = text.rangeOfCharacter(from: .newlines, options: [], range: window).location + var end = lineBreak == NSNotFound ? NSMaxRange(window) : lineBreak + if end < length { + let lastCharacter = text.rangeOfComposedCharacterSequence(at: end - 1) + if NSMaxRange(lastCharacter) > end { + end = lastCharacter.location + } + } + return text.substring(with: NSRange(location: start, length: end - start)) + .trimmingCharacters(in: .whitespaces) + } + return nil + } +} diff --git a/apple/TableProMobile/TableProMobile/Helpers/SavedQueryPersistence.swift b/apple/TableProMobile/TableProMobile/Helpers/SavedQueryPersistence.swift new file mode 100644 index 0000000000..987ba529b8 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/SavedQueryPersistence.swift @@ -0,0 +1,49 @@ +import Foundation + +// The records of deleted connections, removed once the saved queries can be written. +nonisolated struct SavedQueryPurge: Codable, Equatable, Sendable { + let connectionIds: Set + // Only the device that deleted the connection deletes the records in iCloud. + let tombstoning: Bool +} + +nonisolated struct SavedQueryPersistence { + let directory: URL + + var fileURL: URL { + directory.appendingPathComponent("saved-queries.json") + } + + var purgesURL: URL { + directory.appendingPathComponent("saved-query-purges.json") + } + + func save(_ contents: SavedQueryContents) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(contents) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + func load() throws -> SavedQueryContents { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return SavedQueryContents() } + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(SavedQueryContents.self, from: data) + } + + func savePurges(_ purges: [SavedQueryPurge]) throws { + guard !purges.isEmpty else { + guard FileManager.default.fileExists(atPath: purgesURL.path) else { return } + try FileManager.default.removeItem(at: purgesURL) + return + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(purges) + try data.write(to: purgesURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + func loadPurges() throws -> [SavedQueryPurge] { + guard FileManager.default.fileExists(atPath: purgesURL.path) else { return [] } + let data = try Data(contentsOf: purgesURL) + return try JSONDecoder().decode([SavedQueryPurge].self, from: data) + } +} diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 1d5aa89c07..3822c237b3 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -29,6 +29,34 @@ } } }, + "\"%@\" will be permanently deleted." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\" 항목이 영구적으로 삭제됩니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\" sẽ bị xoá vĩnh viễn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\" 将被永久删除。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」將會永久刪除。" + } + } + } + }, "“%@” is not on any connection." : { "localizations" : { "ko" : { @@ -701,6 +729,7 @@ } }, "%d connections imported." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -728,6 +757,34 @@ } } }, + "%d connections were imported." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d개 연결을 가져왔습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã nhập %d kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已导入%d个连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已匯入 %d 個連線。" + } + } + } + }, "%d connections will be exported." : { "localizations" : { "ko" : { @@ -783,6 +840,18 @@ } } } + }, + "%d saved queries that belong to these connections, with their folders and keywords." : { + + }, + "%d saved queries that every connection shows." : { + + }, + "%d saved queries were added." : { + + }, + "%d saved queries were not imported." : { + }, "%ld is not a TCP port." : { "extractionState" : "manual", @@ -978,6 +1047,7 @@ }, "1 connection imported." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -1005,6 +1075,34 @@ } } }, + "1 connection was imported." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1개 연결을 가져왔습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 kết nối đã được nhập." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已导入 1 个连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已匯入 1 個連線。" + } + } + } + }, "1 connection will be exported." : { "localizations" : { "ko" : { @@ -1066,6 +1164,18 @@ } } } + }, + "1 saved query that belongs to these connections, with its folder and keyword." : { + + }, + "1 saved query that every connection shows." : { + + }, + "1 saved query was added." : { + + }, + "1 saved query was not imported." : { + }, "A database named “%@” already exists." : { "localizations" : { @@ -1179,6 +1289,9 @@ } } } + }, + "A saved query with this name exists." : { + }, "A string cannot hold a line break, tab or other control character." : { @@ -2004,6 +2117,9 @@ } } } + }, + "All connections" : { + }, "All columns" : { "extractionState" : "manual", @@ -2092,6 +2208,9 @@ } } } + }, + "Also include global saved queries" : { + }, "Americas" : { "extractionState" : "manual", @@ -2190,6 +2309,9 @@ "Another connection to %@ with other settings is still logging in. Try again once it finishes." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Another selected row adds the same query." : { + }, "Antenna" : { "extractionState" : "manual", @@ -2582,6 +2704,34 @@ } } }, + "Available in all connections" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 연결에서 사용 가능" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Có trong mọi kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在所有连接中可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在所有連線中可用" + } + } + } + }, "Bag" : { "extractionState" : "manual", "generatesSymbol" : false @@ -3008,6 +3158,9 @@ } } } + }, + "Change Not Saved" : { + }, "Changes from iCloud could not be saved on this device. They download again on the next sync." : { @@ -5843,6 +5996,62 @@ } } }, + "Delete Folder" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "폴더 삭제" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xoá thư mục" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除文件夹" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除資料夾" + } + } + } + }, + "Delete Folder?" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "폴더를 삭제하시겠습니까?" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xoá thư mục?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除文件夹?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除資料夾?" + } + } + } + }, "Delete group" : { "extractionState" : "stale", "localizations" : { @@ -5955,6 +6164,9 @@ } } } + }, + "Delete Saved Query?" : { + }, "Delete Tag" : { "localizations" : { @@ -6413,6 +6625,9 @@ } } } + }, + "Edit Query" : { + }, "Edit JSON" : { @@ -6478,6 +6693,34 @@ }, "Edit the connection and set a key for this jump host under Jump Hosts." : { + }, + "Edit…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sửa…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯…" + } + } + } }, "Empty String" : { "localizations" : { @@ -6853,6 +7096,34 @@ } } }, + "global" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "toàn cục" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全局" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "全域" + } + } + } + }, "Error" : { "localizations" : { "ko" : { @@ -6912,6 +7183,9 @@ } } } + }, + "Everything selected was already in your library." : { + }, "Execute" : { "localizations" : { @@ -7784,6 +8058,9 @@ "Folder" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Folder Deleted" : { + }, "Foreign Keys" : { "localizations" : { @@ -7883,6 +8160,34 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "Global" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toàn cục" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全局" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "全域" + } + } + } + }, "Globe" : { "extractionState" : "manual", "generatesSymbol" : false @@ -8872,6 +9177,34 @@ } } }, + "Import Complete" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가져오기 완료" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập Hoàn tất" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入完成" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯入完成" + } + } + } + }, "Import Connections" : { "localizations" : { "ko" : { @@ -8899,6 +9232,9 @@ } } } + }, + "Import Failed" : { + }, "Import connections from your Mac" : { "extractionState" : "stale", @@ -8956,6 +9292,9 @@ } } } + }, + "Import Incomplete" : { + }, "Import PKCS#12" : { "localizations" : { @@ -9096,6 +9435,9 @@ } } } + }, + "Include Saved Queries" : { + }, "Include passwords" : { "localizations" : { @@ -9484,6 +9826,34 @@ } } }, + "keyword: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "키워드: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "từ khoá: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关键字:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關鍵字:%@" + } + } + } + }, "It never contains a hostname, username, password, query, or any data from your databases. You can change this in Settings." : { "localizations" : { "ko" : { @@ -9511,6 +9881,9 @@ } } } + }, + "Its connection is not imported." : { + }, "Its changes are saved to the database." : { @@ -9653,8 +10026,12 @@ } } } + }, + "Keep Existing, Add Queries" : { + }, "Keep your connections, groups, and tags the same on your iPhone, iPad, and Mac." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -9681,6 +10058,9 @@ } } } + }, + "Keep your connections, groups, tags, and saved queries the same on your iPhone, iPad, and Mac." : { + }, "Kerberos authentication failed: %@" : { "extractionState" : "manual", @@ -9755,6 +10135,71 @@ }, "Keys of type %@ cannot be opened here." : { + }, + "Keyword" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "키워드" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Từ khoá" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关键字" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關鍵字" + } + } + } + }, + "Keyword cannot contain spaces" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "키워드에는 공백을 포함할 수 없습니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Từ khoá không được chứa khoảng trắng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关键字不能包含空格" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關鍵字不能包含空格" + } + } + } + }, + "Keyword “%@”" : { + + }, + "Keyword “%@” is in use, so it imports without one." : { + + }, + "Keyword “%@” is not valid, so it imports without one." : { + }, "Laptop" : { "extractionState" : "manual", @@ -10768,6 +11213,12 @@ } } } + }, + "Move to Folder" : { + + }, + "Move to Folder…" : { + }, "Move to Group" : { "localizations" : { @@ -11041,6 +11492,34 @@ } } }, + "New Folder" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 폴더" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thư mục mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建文件夹" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增資料夾" + } + } + } + }, "New Group" : { "localizations" : { "ko" : { @@ -11071,6 +11550,34 @@ }, "New Jump Host" : { + }, + "New Subfolder" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 하위 폴더" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thư mục con mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建子文件夹" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增子資料夾" + } + } + } }, "New Subgroup" : { "localizations" : { @@ -11613,6 +12120,9 @@ } } } + }, + "No Saved Queries" : { + }, "No SSH username is set for %@." : { @@ -11994,6 +12504,9 @@ } } } + }, + "Nothing Imported" : { + }, "Not Signed In to iCloud" : { @@ -12240,6 +12753,9 @@ } } } + }, + "Only folders available in all connections can hold a global query." : { + }, "open" : { "localizations" : { @@ -12353,6 +12869,34 @@ } } }, + "Open in Editor" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집기에서 열기" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở trong trình soạn thảo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在编辑器中打开" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在編輯器中開啟" + } + } + } + }, "Open Connection" : { "localizations" : { "ko" : { @@ -13715,6 +14259,9 @@ } } } + }, + "Queries you save from the Query menu appear here." : { + }, "Query" : { "localizations" : { @@ -14453,6 +15000,37 @@ } } } + }, + "Rename" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이름 변경" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đổi tên" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重命名" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新命名" + } + } + } + }, + "Rename Folder" : { + }, "Replace" : { "localizations" : { @@ -14481,6 +15059,9 @@ } } } + }, + "Replaces the editor's text with this query." : { + }, "Report" : { "extractionState" : "manual", @@ -15378,6 +15959,46 @@ } } } + }, + "Save Query" : { + + }, + "Save Query…" : { + + }, + "Saved Queries" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장된 쿼리" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn đã lưu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已保存的查询" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已儲存的查詢" + } + } + } + }, + "Saved Queries Unavailable" : { + + }, + "Saved Query Deleted" : { + }, "Saved credentials for these connections will be permanently removed." : { "localizations" : { @@ -15410,6 +16031,15 @@ "Saved passwords can only be exported to an encrypted file." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Saved queries are limited to %@ so they fit in iCloud." : { + + }, + "Saved queries are written as readable text unless the file is encrypted." : { + + }, + "Saved queries can't be read right now, so they are not imported." : { + }, "Scatter Plot" : { "extractionState" : "manual", @@ -15538,6 +16168,9 @@ }, "Search Icons" : { + }, + "Search Saved Queries" : { + }, "Search tables" : { "localizations" : { @@ -15929,6 +16562,34 @@ } } }, + "Shadows the SQL keyword '%@'" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "SQL 키워드 '%@'와 이름이 겹칩니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trùng với từ khoá SQL '%@'" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与 SQL 关键字 '%@' 冲突" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "與 SQL 關鍵字「%@」衝突" + } + } + } + }, "Shard" : { "extractionState" : "manual", "generatesSymbol" : false @@ -17756,6 +18417,9 @@ } } } + }, + "TablePro could not read your connection library, so nothing was imported." : { + }, "TablePro could not read your saved connections. Nothing on this device has been changed." : { "localizations" : { @@ -17784,6 +18448,12 @@ } } } + }, + "TablePro could not read your saved queries. Nothing on this device has been changed." : { + + }, + "TablePro could not write your saved queries, so this change was not saved." : { + }, "TablePro Data Was Removed from iCloud" : { @@ -18642,6 +19312,9 @@ "The connection was lost while a transaction was open. Check which of its changes were saved before running them again." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "The connections could not be saved." : { + }, "The CSV data has no header row." : { "localizations" : { @@ -18794,6 +19467,34 @@ } } }, + "The folder \"%@\" will be deleted. Items inside will be moved to the parent level." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\" 폴더가 삭제됩니다. 폴더 안의 항목은 상위 단계로 이동됩니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thư mục \"%@\" sẽ bị xoá. Các mục bên trong sẽ được chuyển lên cấp cha." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件夹 \"%@\" 将被删除。其中的项目将移至上一级。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料夾「%@」將被刪除。其中的項目將移至上一層。" + } + } + } + }, "The failed transaction was rolled back. None of its changes were kept." : { }, @@ -19496,6 +20197,12 @@ } } } + }, + "The saved queries could not be read. Nothing was exported." : { + + }, + "The saved queries could not be saved." : { + }, "The server is not responding. Check the host and port." : { "localizations" : { @@ -20390,6 +21097,9 @@ } } } + }, + "This folder no longer exists. It may have been removed from another device." : { + }, "This group can't go inside the parent you chose. It may have changed on another device." : { "localizations" : { @@ -20451,6 +21161,34 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "This keyword is already in use" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 키워드는 이미 사용 중입니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Từ khoá này đã được sử dụng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此关键字已被使用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此關鍵字已被使用" + } + } + } + }, "This JSON document is too large for tree view. Use text mode instead." : { }, @@ -20521,6 +21259,9 @@ }, "This row's primary key is not plain text, so it cannot identify the row." : { + }, + "This saved query no longer exists. It may have been removed from another device." : { + }, "This server has no password set for the default user. Clear the Password field." : { "extractionState" : "manual", @@ -20981,6 +21722,9 @@ "Token" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Too large to save (%@)" : { + }, "Too many rows. Add up to %lld rows at a time." : { "localizations" : { @@ -21590,7 +22334,33 @@ }, "Untitled" : { "extractionState" : "manual", - "generatesSymbol" : false + "generatesSymbol" : false, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목 없음" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có tiêu đề" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未命名" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未命名" + } + } + } }, "Update Required" : { @@ -22581,6 +23351,9 @@ } } } + }, + "already saved" : { + } }, "version" : "1.0" diff --git a/apple/TableProMobile/TableProMobile/Models/SavedQueryContents.swift b/apple/TableProMobile/TableProMobile/Models/SavedQueryContents.swift new file mode 100644 index 0000000000..a78bf6fa11 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Models/SavedQueryContents.swift @@ -0,0 +1,92 @@ +import Foundation +import TableProConnectionLibrary +import TableProModels + +nonisolated struct SavedQueryContents: Codable, Equatable, Sendable { + var queries: [SavedQuery] + var folders: [SavedQueryFolder] + + init(queries: [SavedQuery] = [], folders: [SavedQueryFolder] = []) { + self.queries = queries + self.folders = folders + } + + private enum CodingKeys: String, CodingKey { + case queries, folders + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + queries = try container.decodeIfPresent([SavedQuery].self, forKey: .queries) ?? [] + folders = try container.decodeIfPresent([SavedQueryFolder].self, forKey: .folders) ?? [] + } + + var isEmpty: Bool { + queries.isEmpty && folders.isEmpty + } + + func visible(to connectionId: UUID) -> SavedQueryContents { + SavedQueryContents( + queries: SavedQueryOutline.visible(queries, to: connectionId), + folders: SavedQueryOutline.visible(folders, to: connectionId) + ) + } + + func query(_ id: UUID) -> SavedQuery? { + queries.first { $0.id == id } + } + + func folder(_ id: UUID) -> SavedQueryFolder? { + folders.first { $0.id == id } + } + + // What sat inside a deleted folder moves up to its parent, as on the Mac. + mutating func removeFolder(_ id: UUID) -> (removed: SavedQueryFolder, moved: SavedQueryContents)? { + guard let index = folders.firstIndex(where: { $0.id == id }) else { return nil } + let removed = folders.remove(at: index) + var moved = SavedQueryContents() + for index in queries.indices where queries[index].folderId == id { + queries[index].folderId = removed.parentId + moved.queries.append(queries[index]) + } + for index in folders.indices where folders[index].parentId == id { + folders[index].parentId = removed.parentId + moved.folders.append(folders[index]) + } + return (removed, moved) + } + + // A survivor that pointed into a removed folder is detached to the top level, and reported so it can be pushed. + mutating func removeRecords(scopedTo connectionIds: Set) -> (removed: SavedQueryContents, detached: SavedQueryContents) { + var removed = SavedQueryContents() + removed.queries = queries.filter { $0.connectionId.map(connectionIds.contains) == true } + removed.folders = folders.filter { $0.connectionId.map(connectionIds.contains) == true } + guard !removed.isEmpty else { return (removed, SavedQueryContents()) } + + let removedQueryIds = Set(removed.queries.map(\.id)) + let removedFolderIds = Set(removed.folders.map(\.id)) + queries.removeAll { removedQueryIds.contains($0.id) } + folders.removeAll { removedFolderIds.contains($0.id) } + + var detached = SavedQueryContents() + for index in queries.indices where queries[index].folderId.map(removedFolderIds.contains) == true { + queries[index].folderId = nil + detached.queries.append(queries[index]) + } + for index in folders.indices where folders[index].parentId.map(removedFolderIds.contains) == true { + folders[index].parentId = nil + detached.folders.append(folders[index]) + } + return (removed, detached) + } + + func isFolder(_ candidate: UUID, insideOrEqualTo folderId: UUID) -> Bool { + var visited: Set = [] + var current: UUID? = candidate + while let id = current, visited.insert(id).inserted { + if id == folderId { return true } + current = folder(id)?.parentId + } + return false + } +} diff --git a/apple/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift b/apple/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift index 6592a6b2c1..4b251abf5c 100644 --- a/apple/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift +++ b/apple/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift @@ -109,7 +109,7 @@ private struct ICloudPage: View { OnboardingPageLayout { OnboardingHeader( title: "Sync with iCloud", - message: "Keep your connections, groups, and tags the same on your iPhone, iPad, and Mac.", + message: "Keep your connections, groups, tags, and saved queries the same on your iPhone, iPad, and Mac.", image: .symbol("icloud") ) } content: { diff --git a/apple/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift b/apple/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift index 76ed535404..31264b5416 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift @@ -4,22 +4,54 @@ import TableProDatabase import TableProImport import TableProModels +nonisolated enum IOSConnectionExportError: LocalizedError, Equatable { + case savedQueriesUnreadable + + var errorDescription: String? { + switch self { + case .savedQueriesUnreadable: + String(localized: "The saved queries could not be read. Nothing was exported.") + } + } +} + @MainActor enum IOSConnectionExportService { static func exportData( connections: [DatabaseConnection], appState: AppState, includeCredentials: Bool, + includeSavedQueries: Bool = true, + includeGlobalSavedQueries: Bool = false, passphrase: String? ) async throws -> Data { + var savedQueries: SavedQueryContents? + if includeSavedQueries { + guard let readable = readableSavedQueries(in: appState) else { + throw IOSConnectionExportError.savedQueriesUnreadable + } + savedQueries = readable + } let bundle = try BundleExportAssembler.assemble( - exportInput(connections, appState: appState, includeCredentials: includeCredentials), - options: BundleExportOptions(includesCredentials: includeCredentials, includesSavedQueries: false), + exportInput(connections, appState: appState, includeCredentials: includeCredentials, savedQueries: savedQueries), + options: BundleExportOptions( + includesCredentials: includeCredentials, + includesSavedQueries: includeSavedQueries, + includesGlobalSavedQueries: includeSavedQueries && includeGlobalSavedQueries + ), appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" ) return try await ConnectionBundleCodec.encode(bundle, passphrase: includeCredentials ? passphrase ?? "" : "") } + // Nil when the saved queries cannot be read, which is not the same as having none. + static func savedQueryCounts(for connections: [DatabaseConnection], appState: AppState) -> SavedQueryCounts? { + guard let readable = readableSavedQueries(in: appState) else { return nil } + return BundleExportAssembler.savedQueryCounts( + exportInput(connections, appState: appState, includeCredentials: false, savedQueries: readable) + ) + } + static func suggestedFilename(for connections: [DatabaseConnection]) -> String { if connections.count == 1, let only = connections.first { let base = only.name.isEmpty ? only.host : only.name @@ -49,10 +81,15 @@ enum IOSConnectionExportService { ) } + private static func readableSavedQueries(in appState: AppState) -> SavedQueryContents? { + appState.savedQueries.status == .ready ? appState.savedQueries.contents : nil + } + private static func exportInput( _ connections: [DatabaseConnection], appState: AppState, - includeCredentials: Bool + includeCredentials: Bool, + savedQueries: SavedQueryContents? ) -> BundleExportInput { BundleExportInput( connections: connections.map { connection in @@ -75,6 +112,19 @@ enum IOSConnectionExportService { }, tags: appState.tags.map { BundleExportInput.Tag(id: $0.id, name: $0.name, color: portableColor($0.color)) + }, + queryFolders: (savedQueries?.folders ?? []).map { + BundleExportInput.QueryFolder(id: $0.id, name: $0.name, parentId: $0.parentId, connectionId: $0.connectionId) + }, + savedQueries: (savedQueries?.queries ?? []).map { + BundleExportInput.SavedQuery( + id: $0.id, + name: $0.name, + sql: $0.query, + keyword: $0.keyword, + folderId: $0.folderId, + connectionId: $0.connectionId + ) } ) } diff --git a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index 9bdf289e05..ce1fc5ea94 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -11,11 +11,11 @@ enum IOSConnectionImportService { (DatabaseType.allKnownTypes + [.cockroachdb, .scylladb, .turso]).map(\.rawValue) ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite"]) - static func environment() -> ImportEnvironment { + static func environment(for appState: AppState) -> ImportEnvironment { ImportEnvironment( rules: ImportRules( maximumGroupDepth: LibraryGroupGraph.maxNestingDepth, - supportsSavedQueries: false, + supportsSavedQueries: appState.savedQueries.status == .ready, supportsCredentialProfiles: false ), registeredTypeIds: recognizedTypeIds @@ -27,15 +27,19 @@ enum IOSConnectionImportService { return ConnectionImportAnalyzer.analyze( CollectedImport(bundle: bundle, source: .file(name: fileName)), library: library, - environment: environment() + environment: environment(for: appState) ) } + static func leavesOutSavedQueries(_ preview: ImportPreview) -> Bool { + !preview.environment.rules.supportsSavedQueries && !preview.collected.bundle.savedQueries.isEmpty + } + static func apply(_ plan: ImportPlan, appState: AppState, secureStore: any SecureStore) async -> ImportOutcome { await ImportApplier.apply( plan, library: IOSImportLibraryStore(appState: appState, secureStore: secureStore), - savedQueries: nil + savedQueries: appState.savedQueries.importStore ) } } diff --git a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift index 08faaec314..083abec51c 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift @@ -27,7 +27,7 @@ struct IOSImportLibraryStore: ImportLibraryStore { socketPath: MySQLLocalSocket.path(in: connection.additionalFields) ) ) - }) + }, savedQueries: appState.savedQueries.ledgerEntries() ?? []) } func addImportedProfiles(_ profiles: [PlannedCredentialProfile]) throws -> [BundleRef: UUID] { diff --git a/apple/TableProMobile/TableProMobile/Services/SavedQueryLibrary+Import.swift b/apple/TableProMobile/TableProMobile/Services/SavedQueryLibrary+Import.swift new file mode 100644 index 0000000000..324e1f41a9 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Services/SavedQueryLibrary+Import.swift @@ -0,0 +1,83 @@ +import Foundation +import TableProImport +import TableProModels + +nonisolated struct IOSSavedQueryImportStore: SavedQueryImportStore { + let library: SavedQueryLibrary + + func importSavedQueries(_ queries: [PlannedQuery]) async -> SavedQueryImportWrite? { + await library.importSavedQueries(queries) + } +} + +nonisolated extension SavedQuery { + var ledgerEntry: SavedQueryLedger.Entry { + SavedQueryLedger.Entry(name: name, sql: query, keyword: keyword, connectionId: connectionId) + } +} + +nonisolated extension SavedQueryContents { + // Decided again against the stored rows, so a query a pull wrote after the preview is matched, not added twice. + mutating func importing(_ planned: [PlannedQuery], now: Date) -> SavedQueryImportWrite { + var ledger = SavedQueryLedger(queries.map(\.ledgerEntry)) + var folderNodes = folders.map { + PathNode(id: $0.id, name: $0.name, parentId: $0.parentId, scope: $0.connectionId) + } + var insertedIds: [UUID] = [] + var createdFolderIds: [UUID] = [] + var alreadySaved = 0 + var droppedKeywords = 0 + var tooLarge = 0 + + for query in planned { + let name = SavedQueryImportPlacement.importedName(query) + switch ledger.verdict(name: name, sql: query.sql, keyword: query.keyword, scope: query.connectionId) { + case .alreadySaved: + alreadySaved += 1 + case .tooLarge: + tooLarge += 1 + case .insert(let keyword, let dropped, _): + let path = SavedQueryImportPlacement.holdableFolderPath(query.folderPath, for: query.connectionId) + let resolved = PathTreeResolver.resolve([path], existing: folderNodes) + for node in resolved.created { + folders.append(SavedQueryFolder( + id: node.id, + name: node.name, + parentId: node.parentId, + connectionId: node.scope, + sortOrder: 0, + createdAt: now, + updatedAt: now + )) + createdFolderIds.append(node.id) + } + folderNodes += resolved.created + + let saved = SavedQuery( + name: name, + query: query.sql, + keyword: keyword, + folderId: resolved.leaves.first.flatMap { $0 }, + connectionId: query.connectionId, + sortOrder: 0, + createdAt: now, + updatedAt: now + ) + queries.append(saved) + insertedIds.append(saved.id) + if dropped != nil { + droppedKeywords += 1 + } + ledger.record(saved.ledgerEntry) + } + } + + return SavedQueryImportWrite( + insertedIds: insertedIds, + createdFolderIds: createdFolderIds, + alreadySaved: alreadySaved, + droppedKeywords: droppedKeywords, + tooLarge: tooLarge + ) + } +} diff --git a/apple/TableProMobile/TableProMobile/Services/SavedQueryLibrary.swift b/apple/TableProMobile/TableProMobile/Services/SavedQueryLibrary.swift new file mode 100644 index 0000000000..cf86b1c35d --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Services/SavedQueryLibrary.swift @@ -0,0 +1,271 @@ +import Foundation +import Observation +import os +import TableProImport +import TableProModels + +@MainActor @Observable +final class SavedQueryLibrary { + private static let logger = Logger(subsystem: "com.TablePro", category: "SavedQueryLibrary") + + private var state: Loadable = .loading + + var status: LoadStatus { state.status } + var contents: SavedQueryContents { state.value ?? SavedQueryContents() } + + // Nil while the file is not loaded, so sync never merges into or pushes from an empty stand-in. + var syncContents: SavedQueryContents? { state.value } + + // Whether records owned by this connection leave the device; the sample's never do. + @ObservationIgnored var syncsOwner: (UUID) -> Bool = { _ in true } + + @ObservationIgnored private let persistence: SavedQueryPersistence + @ObservationIgnored private let syncCoordinator: IOSSyncCoordinator + @ObservationIgnored private var deferredPurges: [SavedQueryPurge] = [] + + init(directory: URL, syncCoordinator: IOSSyncCoordinator) { + persistence = SavedQueryPersistence(directory: directory) + self.syncCoordinator = syncCoordinator + do { + deferredPurges = try persistence.loadPurges() + } catch { + Self.logger.error("Deferred saved query purges failed to load: \(error.localizedDescription, privacy: .public)") + } + load() + } + + var importStore: IOSSavedQueryImportStore { + IOSSavedQueryImportStore(library: self) + } + + func contents(visibleTo connectionId: UUID) -> SavedQueryContents { + contents.visible(to: connectionId) + } + + // MARK: - Load + + func retryLoadIfFailed() { + guard status == .failed else { return } + Self.logger.info("Retrying the saved queries load after a failure") + load() + } + + private func load() { + do { + state = .loaded(try persistence.load()) + } catch { + state = .failed(error) + Self.logger.error("Saved queries load failed: \(error.localizedDescription, privacy: .public)") + return + } + runDeferredPurges() + } + + // MARK: - Queries + + @discardableResult + func insert(_ query: SavedQuery) -> Bool { + guard var updated = writableContents(), updated.query(query.id) == nil else { return false } + updated.queries.append(query) + guard commit(updated) else { return false } + markDirty(queries: [query]) + return true + } + + // The edit runs on the stored row, so a change a pull made while a form was open survives the save. + @discardableResult + func update(_ id: UUID, _ edit: (inout SavedQuery) -> Void) -> Bool { + guard var updated = writableContents(), + let index = updated.queries.firstIndex(where: { $0.id == id }) else { return false } + let before = updated.queries[index] + var query = before + edit(&query) + guard query != before else { return true } + query.updatedAt = Date() + updated.queries[index] = query + guard commit(updated) else { return false } + markDirty(queries: [query]) + return true + } + + @discardableResult + func deleteQuery(_ id: UUID) -> Bool { + guard var updated = writableContents(), + let index = updated.queries.firstIndex(where: { $0.id == id }) else { return false } + let removed = updated.queries.remove(at: index) + guard commit(updated) else { return false } + markDeleted(queries: [removed]) + return true + } + + func isKeywordAvailable(_ keyword: String, connectionId: UUID?, excluding excludedId: UUID? = nil) -> Bool { + guard let keyword = SavedQueryKeyword.normalized(keyword) else { return true } + let holderScopes = contents.queries + .filter { $0.id != excludedId && $0.keyword == keyword } + .map(\.connectionId) + return SavedQueryKeyword.isAvailable(keyword, scope: connectionId, holderScopes: holderScopes) + } + + // MARK: - Folders + + @discardableResult + func insertFolder(_ folder: SavedQueryFolder) -> Bool { + guard var updated = writableContents(), updated.folder(folder.id) == nil else { return false } + if let parentId = folder.parentId, updated.isFolder(parentId, insideOrEqualTo: folder.id) { return false } + updated.folders.append(folder) + guard commit(updated) else { return false } + markDirty(folders: [folder]) + return true + } + + @discardableResult + func updateFolder(_ id: UUID, _ edit: (inout SavedQueryFolder) -> Void) -> Bool { + guard var updated = writableContents(), + let index = updated.folders.firstIndex(where: { $0.id == id }) else { return false } + let before = updated.folders[index] + var folder = before + edit(&folder) + guard folder != before else { return true } + if let parentId = folder.parentId, updated.isFolder(parentId, insideOrEqualTo: id) { return false } + folder.updatedAt = Date() + updated.folders[index] = folder + guard commit(updated) else { return false } + markDirty(folders: [folder]) + return true + } + + @discardableResult + func deleteFolder(_ id: UUID) -> Bool { + guard var updated = writableContents() else { return false } + guard let deletion = updated.removeFolder(id) else { return false } + guard commit(updated) else { return false } + markDeleted(folders: [deletion.removed]) + markDirty(queries: deletion.moved.queries, folders: deletion.moved.folders) + return true + } + + // MARK: - Connections + + // A purge that cannot be written now is kept on disk and runs once the saved queries load and write again. + func removeRecords(ofConnections connectionIds: Set, tombstoning: Bool) { + guard !connectionIds.isEmpty else { return } + let pending = deferredPurges + [SavedQueryPurge(connectionIds: connectionIds, tombstoning: tombstoning)] + deferPurges(purge(pending) ? [] : pending) + } + + private func purge(_ purges: [SavedQueryPurge]) -> Bool { + guard var updated = writableContents() else { return false } + var deleted = SavedQueryContents() + var detachedIds: (queries: Set, folders: Set) = ([], []) + for purge in purges { + let change = updated.removeRecords(scopedTo: purge.connectionIds) + guard purge.tombstoning else { continue } + deleted.queries += change.removed.queries + deleted.folders += change.removed.folders + detachedIds.queries.formUnion(change.detached.queries.map(\.id)) + detachedIds.folders.formUnion(change.detached.folders.map(\.id)) + } + guard updated != state.value else { return true } + guard write(updated) else { return false } + markDeleted(queries: deleted.queries, folders: deleted.folders) + markDirty( + queries: updated.queries.filter { detachedIds.queries.contains($0.id) }, + folders: updated.folders.filter { detachedIds.folders.contains($0.id) } + ) + return true + } + + private func runDeferredPurges() { + guard !deferredPurges.isEmpty, purge(deferredPurges) else { return } + deferPurges([]) + } + + private func deferPurges(_ purges: [SavedQueryPurge]) { + guard purges != deferredPurges else { return } + deferredPurges = purges + do { + try persistence.savePurges(purges) + } catch { + Self.logger.error("Failed to save deferred saved query purges: \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: - Import + + func ledgerEntries() -> [SavedQueryLedger.Entry]? { + state.value?.queries.map(\.ledgerEntry) + } + + func importSavedQueries(_ planned: [PlannedQuery], now: Date = Date()) -> SavedQueryImportWrite? { + guard var updated = writableContents() else { return nil } + let write = updated.importing(planned, now: now) + guard !write.insertedIds.isEmpty || !write.createdFolderIds.isEmpty else { return write } + guard commit(updated) else { return nil } + let inserted = Set(write.insertedIds) + let created = Set(write.createdFolderIds) + markDirty( + queries: updated.queries.filter { inserted.contains($0.id) }, + folders: updated.folders.filter { created.contains($0.id) } + ) + return write + } + + // MARK: - Sync + + func applySynced(_ merged: SavedQueryContents) -> Bool { + guard let current = state.value else { return false } + guard merged != current else { return true } + return commit(merged) + } + + // MARK: - Writing + + private func writableContents() -> SavedQueryContents? { + guard let current = state.value else { + Self.logger.error("Refusing a saved query write while the stored saved queries are not loaded") + return nil + } + return current + } + + // A purge a failed write deferred runs on the next write that lands. + private func commit(_ updated: SavedQueryContents) -> Bool { + guard write(updated) else { return false } + runDeferredPurges() + return true + } + + private func write(_ updated: SavedQueryContents) -> Bool { + do { + try persistence.save(updated) + } catch { + Self.logger.error("Failed to save saved queries: \(error.localizedDescription, privacy: .public)") + return false + } + state = .loaded(updated) + return true + } + + // MARK: - Sync Marks + + private func syncs(_ owner: UUID?) -> Bool { + owner.map(syncsOwner) ?? true + } + + // A record moved to a connection that never syncs is held, as on the Mac: its iCloud copy stays as it was. + private func markDirty(queries: [SavedQuery] = [], folders: [SavedQueryFolder] = []) { + let queryIds = queries.filter { syncs($0.connectionId) }.map(\.id) + let folderIds = folders.filter { syncs($0.connectionId) }.map(\.id) + guard !queryIds.isEmpty || !folderIds.isEmpty else { return } + syncCoordinator.markDirty(savedQueries: queryIds, folders: folderIds) + syncCoordinator.scheduleSyncAfterChange() + } + + private func markDeleted(queries: [SavedQuery] = [], folders: [SavedQueryFolder] = []) { + let queryIds = queries.filter { syncs($0.connectionId) }.map(\.id) + let folderIds = folders.filter { syncs($0.connectionId) }.map(\.id) + guard !queryIds.isEmpty || !folderIds.isEmpty else { return } + syncCoordinator.markDeleted(savedQueries: queryIds, folders: folderIds) + syncCoordinator.scheduleSyncAfterChange() + } +} diff --git a/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index 38b18478e4..8a2bc0c489 100644 --- a/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -29,8 +29,19 @@ final class IOSSyncCoordinator { case superseded } + private enum RemoteApply { + case saved + // Connections, groups and tags landed and the saved queries did not, so only the token waits. + case savedQueriesHeld + case notSaved + } + private static let logger = Logger(subsystem: "com.TablePro", category: "Sync") + static let readableRecordTypes = Set( + [SyncRecordType.connection, .group, .tag, .favorite, .favoriteFolder].map(\.rawValue) + ) + private(set) var status: SyncStatus private(set) var lastSyncDate: Date? @@ -63,10 +74,13 @@ final class IOSSyncCoordinator { @ObservationIgnored private var editGenerations: [EditKey: Int] = [:] @ObservationIgnored private var accountChangeObservation: AnyCancellable? - @ObservationIgnored var onConnectionsChanged: (([DatabaseConnection]) -> Void)? - @ObservationIgnored var onGroupsChanged: (([ConnectionGroup]) -> Void)? - @ObservationIgnored var onTagsChanged: (([ConnectionTag]) -> Void)? + // Each returns whether the merged list was saved; false holds the change token for a replay. + @ObservationIgnored var onConnectionsChanged: (([DatabaseConnection]) -> Bool)? + @ObservationIgnored var onGroupsChanged: (([ConnectionGroup]) -> Bool)? + @ObservationIgnored var onTagsChanged: (([ConnectionTag]) -> Bool)? @ObservationIgnored var getCurrentState: (() -> LibraryState?)? + @ObservationIgnored var getSavedQueries: (() -> SavedQueryContents?)? + @ObservationIgnored var onSavedQueriesChanged: ((SavedQueryContents) -> Bool)? private static var recordCacheDirectory: URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first @@ -360,27 +374,39 @@ final class IOSSyncCoordinator { let connCount = changes.changedConnections.count let groupCount = changes.changedGroups.count let tagCount = changes.changedTags.count - Self.logger.info("Pulled \(connCount) connections, \(groupCount) groups, \(tagCount) tags") + let queryCount = changes.savedQueries.changedQueries.count + let folderCount = changes.savedQueries.changedFolders.count + Self.logger.info( + "Pulled \(connCount) connections, \(groupCount) groups, \(tagCount) tags, \(queryCount) saved queries, \(folderCount) folders" + ) - guard applyRemoteChanges(changes) else { + let applied = applyRemoteChanges(changes) + guard applied != .notSaved else { return RunResult(admission: admission, failure: .pullNotSaved) } - /// Committed before the upload, so an upload that fails does not make every later run - /// download and merge everything since the last clean sync again. - if let newToken = pulled.newToken { + // Committed before the upload, so a failed upload does not make every later run download everything again. + if applied == .saved, let newToken = pulled.newToken { metadata.saveToken(newToken) } + // The connection writes landed, and a dirty connection pushed over a stale change tag would conflict. recordCache.store(pulled.changedRecords) recordCache.remove(pulled.deletedRecordIDs) + let heldDownload: SyncStepFailure? = applied == .saved ? nil : .pullNotSaved - guard admission == .full else { return RunResult(admission: admission, failure: nil) } + guard admission == .full else { return RunResult(admission: admission, failure: heldDownload) } guard let pushFailure = await push(using: transport) else { - return RunResult(admission: admission, failure: nil) + return RunResult(admission: admission, failure: heldDownload) } - return RunResult(admission: admission, failure: await refined(pushFailure, using: transport)) + let upload = await refined(pushFailure, using: transport) + return RunResult(admission: admission, failure: SyncStepFailure.decisive(upload: upload, download: heldDownload)) } private func fetchChanges(using transport: any IOSSyncTransport) async throws -> PullResult { + // A delta never resends what an earlier build skipped, so a build that reads more types fetches everything once. + if metadata.adoptReadableRecordTypes(Self.readableRecordTypes) { + Self.logger.notice("This build reads record types an earlier one skipped, so it fetches everything once") + metadata.saveToken(nil) + } do { return try await transport.pull(since: metadata.loadToken()) } catch let error where SyncFailure(error) == .tokenExpired { @@ -511,6 +537,16 @@ final class IOSSyncCoordinator { addTombstone(tagId.uuidString, type: .tag) } + func markDirty(savedQueries queryIds: [UUID], folders folderIds: [UUID] = []) { + markDirty(queryIds.map(\.uuidString), type: .favorite) + markDirty(folderIds.map(\.uuidString), type: .favoriteFolder) + } + + func markDeleted(savedQueries queryIds: [UUID], folders folderIds: [UUID] = []) { + metadata.addTombstones(queryIds.map(\.uuidString), type: .favorite) + metadata.addTombstones(folderIds.map(\.uuidString), type: .favoriteFolder) + } + private func markDirty(_ ids: [String], type: SyncRecordType) { for id in ids { editGenerations[EditKey(type: type, id: id), default: 0] += 1 @@ -527,6 +563,23 @@ final class IOSSyncCoordinator { markDirty(state.connections.filter(\.participatesInSync).map(\.id.uuidString), type: .connection) markDirty(state.groups.map(\.id.uuidString), type: .group) markDirty(state.tags.map(\.id.uuidString), type: .tag) + guard let savedQueries = getSavedQueries?() else { return } + let syncs = Self.ownerSyncs(in: state.connections) + markDirty(savedQueries.queries.filter { syncs($0.connectionId) }.map(\.id.uuidString), type: .favorite) + markDirty(savedQueries.folders.filter { syncs($0.connectionId) }.map(\.id.uuidString), type: .favoriteFolder) + } + + // A record owned by a connection that never leaves the device (the sample) stays local too. + private static func ownersKeptOff(in connections: [DatabaseConnection]) -> Set { + Set(connections.filter { !$0.participatesInSync }.map(\.id)) + } + + private static func ownerSyncs(keptOff: Set) -> (UUID?) -> Bool { + { owner in owner.map { !keptOff.contains($0) } ?? true } + } + + private static func ownerSyncs(in connections: [DatabaseConnection]) -> (UUID?) -> Bool { + ownerSyncs(keptOff: ownersKeptOff(in: connections)) } /// An edit made while a run is in flight queues one more run instead of cancelling it, because @@ -546,14 +599,61 @@ final class IOSSyncCoordinator { // MARK: - Apply - private func applyRemoteChanges(_ remote: PullChanges) -> Bool { - guard let state = getCurrentState?() else { return false } - onConnectionsChanged?(mergeConnections(local: state.connections, remote: remote)) - onGroupsChanged?(mergeGroups(local: state.groups, remote: remote)) - onTagsChanged?(mergeTags(local: state.tags, remote: remote)) + private func applyRemoteChanges(_ remote: PullChanges) -> RemoteApply { + guard let state = getCurrentState?() else { return .notSaved } + let connectionsSaved = onConnectionsChanged?(mergeConnections(local: state.connections, remote: remote)) ?? true + let groupsSaved = onGroupsChanged?(mergeGroups(local: state.groups, remote: remote)) ?? true + let tagsSaved = onTagsChanged?(mergeTags(local: state.tags, remote: remote)) ?? true + guard connectionsSaved, groupsSaved, tagsSaved else { + Self.logger.error("A pulled library change was not saved, so the change token is held") + return .notSaved + } + return applyRemoteSavedQueries(remote.savedQueries) ? .saved : .savedQueriesHeld + } + + // Read after the connection callbacks ran, so a connection deleted elsewhere has already taken its records. + private func applyRemoteSavedQueries(_ remote: SavedQueryRemoteChanges) -> Bool { + guard !remote.isEmpty else { return true } + guard let local = getSavedQueries?() else { + Self.logger.error("Saved queries arrived while the stored ones are not loaded, so the change token is held") + return false + } + let keptOff = Self.ownersKeptOff(in: getCurrentState?()?.connections ?? []) + let outcome = SavedQueryRemoteMerge.merge( + local: local, + remote: remote, + pending: pendingSavedQueryEdits(), + keptLocalOwners: keptOff + ) + if outcome.contents != local { + guard onSavedQueriesChanged?(outcome.contents) == true else { + Self.logger.error("Pulled saved queries were not saved, so the change token is held") + return false + } + } + // Pushed even when nothing changed here: a stale copy that lost its keyword still holds it in iCloud. + guard !outcome.releasedKeywordIds.isEmpty else { return true } + Self.logger.info("Keyword conflicts resolved: \(outcome.releasedKeywordIds.count, privacy: .public)") + let syncs = Self.ownerSyncs(keptOff: keptOff) + let released = Set(outcome.releasedKeywordIds) + let pushed = outcome.contents.queries.filter { released.contains($0.id) && syncs($0.connectionId) } + markDirty(pushed.map(\.id.uuidString), type: .favorite) return true } + private func pendingSavedQueryEdits() -> SavedQueryPendingEdits { + SavedQueryPendingEdits( + dirtyQueryIds: metadata.dirtyIds(for: .favorite), + dirtyFolderIds: metadata.dirtyIds(for: .favoriteFolder), + deletedQueryIds: tombstonedIds(for: .favorite), + deletedFolderIds: tombstonedIds(for: .favoriteFolder) + ) + } + + private func tombstonedIds(for type: SyncRecordType) -> Set { + Set(metadata.tombstones(for: type).map(\.id)) + } + // MARK: - Push /// Whatever went through is settled even when a later batch threw, so a saved record is not @@ -562,7 +662,7 @@ final class IOSSyncCoordinator { let zoneID = await transport.currentZoneID guard let state = getCurrentState?() else { return nil } let generationsAtPush = editGenerations - let batch = pushBatch(from: state, in: zoneID) + let batch = pushBatch(from: state, savedQueries: getSavedQueries?(), in: zoneID) guard !batch.records.isEmpty || !batch.deletions.isEmpty else { return nil } var outcome: PushOutcome @@ -610,7 +710,11 @@ final class IOSSyncCoordinator { } } - private func pushBatch(from state: LibraryState, in zoneID: CKRecordZone.ID) -> (records: [CKRecord], deletions: [CKRecord.ID]) { + private func pushBatch( + from state: LibraryState, + savedQueries: SavedQueryContents?, + in zoneID: CKRecordZone.ID + ) -> (records: [CKRecord], deletions: [CKRecord.ID]) { var records: [CKRecord] = [] var deletions: [CKRecord.ID] = [] @@ -648,7 +752,13 @@ final class IOSSyncCoordinator { } } - for type in [SyncRecordType.connection, .group, .tag] { + var deletedTypes: [SyncRecordType] = [.connection, .group, .tag] + if let savedQueries { + records += savedQueryRecords(savedQueries, connections: state.connections, in: zoneID) + deletedTypes += [.favorite, .favoriteFolder] + } + + for type in deletedTypes { for tombstone in metadata.tombstones(for: type) { deletions.append(SyncRecordMapper.recordID(type: type, id: tombstone.id, in: zoneID)) } @@ -657,6 +767,24 @@ final class IOSSyncCoordinator { return (records, deletions) } + // Built fresh, not from the cache: `.changedKeys` clears a keyword, folder or connection only when the key is named. + private func savedQueryRecords( + _ contents: SavedQueryContents, + connections: [DatabaseConnection], + in zoneID: CKRecordZone.ID + ) -> [CKRecord] { + let syncs = Self.ownerSyncs(in: connections) + let dirtyFolderIds = metadata.dirtyIds(for: .favoriteFolder) + let dirtyQueryIds = metadata.dirtyIds(for: .favorite) + let folders = contents.folders + .filter { dirtyFolderIds.contains($0.id.uuidString) && syncs($0.connectionId) } + .map { SyncRecordMapper.toRecord($0, zoneID: zoneID) } + let queries = contents.queries + .filter { dirtyQueryIds.contains($0.id.uuidString) && syncs($0.connectionId) } + .map { SyncRecordMapper.toRecord($0, zoneID: zoneID) } + return folders + queries + } + // MARK: - Pull private struct PullChanges { @@ -666,6 +794,7 @@ final class IOSSyncCoordinator { var deletedGroupIDs: Set = [] var changedTags: [ConnectionTag] = [] var deletedTagIDs: Set = [] + var savedQueries = SavedQueryRemoteChanges() init(_ result: PullResult) { for record in result.changedRecords { @@ -682,6 +811,14 @@ final class IOSSyncCoordinator { if let tag = SyncRecordMapper.toTag(record) { changedTags.append(tag) } + case SyncRecordType.favorite.rawValue: + if let query = SyncRecordMapper.toSavedQuery(record) { + savedQueries.changedQueries.append(query) + } + case SyncRecordType.favoriteFolder.rawValue: + if let folder = SyncRecordMapper.toSavedQueryFolder(record) { + savedQueries.changedFolders.append(folder) + } default: break } @@ -697,6 +834,10 @@ final class IOSSyncCoordinator { deletedGroupIDs.insert(uuid) case .tag: deletedTagIDs.insert(uuid) + case .favorite: + savedQueries.deletedQueryIds.insert(uuid) + case .favoriteFolder: + savedQueries.deletedFolderIds.insert(uuid) default: break } @@ -711,7 +852,8 @@ final class IOSSyncCoordinator { local: local, changed: remote.changedConnections, deleted: remote.deletedConnectionIDs, - dirtyIds: metadata.dirtyIds(for: .connection) + dirtyIds: metadata.dirtyIds(for: .connection), + tombstonedIds: tombstonedIds(for: .connection) ) } @@ -720,7 +862,8 @@ final class IOSSyncCoordinator { local: local, changed: remote.changedGroups, deleted: remote.deletedGroupIDs, - dirtyIds: metadata.dirtyIds(for: .group) + dirtyIds: metadata.dirtyIds(for: .group), + tombstonedIds: tombstonedIds(for: .group) ) } @@ -729,7 +872,8 @@ final class IOSSyncCoordinator { local: local, changed: remote.changedTags, deleted: remote.deletedTagIDs, - dirtyIds: metadata.dirtyIds(for: .tag) + dirtyIds: metadata.dirtyIds(for: .tag), + tombstonedIds: tombstonedIds(for: .tag) ) } @@ -737,10 +881,13 @@ final class IOSSyncCoordinator { local: [Item], changed: [Item], deleted: Set, - dirtyIds: Set + dirtyIds: Set, + tombstonedIds: Set ) -> [Item] where Item.ID == UUID { var result = local.filter { !deleted.contains($0.id) } - for remoteItem in changed where !deleted.contains(remoteItem.id) { + // A record deleted here and not pushed yet stays deleted, or the pull brings it back before the push. + for remoteItem in changed + where !deleted.contains(remoteItem.id) && !tombstonedIds.contains(remoteItem.id.uuidString) { guard let index = result.firstIndex(where: { $0.id == remoteItem.id }) else { result.append(remoteItem) continue diff --git a/apple/TableProMobile/TableProMobile/Sync/SavedQueryRemoteMerge.swift b/apple/TableProMobile/TableProMobile/Sync/SavedQueryRemoteMerge.swift new file mode 100644 index 0000000000..4610bfa679 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Sync/SavedQueryRemoteMerge.swift @@ -0,0 +1,118 @@ +import Foundation +import TableProConnectionLibrary +import TableProModels + +nonisolated struct SavedQueryRemoteChanges: Equatable, Sendable { + var changedQueries: [SavedQuery] = [] + var deletedQueryIds: Set = [] + var changedFolders: [SavedQueryFolder] = [] + var deletedFolderIds: Set = [] + + var isEmpty: Bool { + changedQueries.isEmpty && deletedQueryIds.isEmpty && changedFolders.isEmpty && deletedFolderIds.isEmpty + } +} + +nonisolated struct SavedQueryPendingEdits: Equatable, Sendable { + var dirtyQueryIds: Set = [] + var dirtyFolderIds: Set = [] + var deletedQueryIds: Set = [] + var deletedFolderIds: Set = [] +} + +nonisolated struct SavedQueryMergeOutcome: Equatable, Sendable { + let contents: SavedQueryContents + // Queries that lost a contested keyword; the release is pushed so every device agrees on it. + let releasedKeywordIds: [UUID] +} + +// The Mac's apply order: query deletions, folder upserts, query upserts through the keyword rule, folder deletions. +nonisolated enum SavedQueryRemoteMerge { + static func merge( + local: SavedQueryContents, + remote: SavedQueryRemoteChanges, + pending: SavedQueryPendingEdits, + keptLocalOwners: Set + ) -> SavedQueryMergeOutcome { + var contents = local + + // A record moved to a connection that never syncs keeps an iCloud copy from before the move, which a pull must not apply. + let keptLocal = { (owner: UUID?) in owner.map(keptLocalOwners.contains) ?? false } + let keptQueryIds = Set(local.queries.filter { keptLocal($0.connectionId) }.map(\.id)) + let keptFolderIds = Set(local.folders.filter { keptLocal($0.connectionId) }.map(\.id)) + let deletedQueryIds = remote.deletedQueryIds.subtracting(keptQueryIds) + let deletedFolderIds = remote.deletedFolderIds.subtracting(keptFolderIds) + + // A deletion made here and not pushed yet keeps the record deleted, or the pull brings it back first. + let incomingQueries = remote.changedQueries.filter { + !remote.deletedQueryIds.contains($0.id) && !pending.deletedQueryIds.contains($0.id.uuidString) + && !keptQueryIds.contains($0.id) + } + let incomingFolders = remote.changedFolders.filter { + !remote.deletedFolderIds.contains($0.id) && !pending.deletedFolderIds.contains($0.id.uuidString) + && !keptFolderIds.contains($0.id) + } + + contents.queries.removeAll { deletedQueryIds.contains($0.id) } + + for folder in incomingFolders { + guard let index = contents.folders.firstIndex(where: { $0.id == folder.id }) else { + contents.folders.append(folder) + continue + } + guard !pending.dirtyFolderIds.contains(folder.id.uuidString) else { continue } + contents.folders[index] = stored(folder, over: contents.folders[index]) + } + + let upserts = incomingQueries.filter { query in + !pending.dirtyQueryIds.contains(query.id.uuidString) || contents.query(query.id) == nil + } + let resolution = SavedQueryKeywordResolver.resolve(incoming: upserts, local: contents.queries) + let releasedLocalIds = Set(resolution.releasedLocalIds) + for index in contents.queries.indices where releasedLocalIds.contains(contents.queries[index].id) { + contents.queries[index].keyword = nil + } + for query in resolution.upserts { + if let index = contents.queries.firstIndex(where: { $0.id == query.id }) { + contents.queries[index] = stored(query, over: contents.queries[index]) + } else { + contents.queries.append(query) + } + } + + for id in deletedFolderIds { + _ = contents.removeFolder(id) + } + + return SavedQueryMergeOutcome(contents: contents, releasedKeywordIds: resolution.releasedIds) + } + + // The Mac never rewrites a stored row's createdAt on upsert, and createdAt decides keyword ownership. + private static func stored(_ incoming: SavedQuery, over existing: SavedQuery) -> SavedQuery { + guard incoming.createdAt != existing.createdAt else { return incoming } + return SavedQuery( + id: incoming.id, + name: incoming.name, + query: incoming.query, + keyword: incoming.keyword, + folderId: incoming.folderId, + connectionId: incoming.connectionId, + sortOrder: incoming.sortOrder, + createdAt: existing.createdAt, + updatedAt: incoming.updatedAt + ) + } + + private static func stored(_ incoming: SavedQueryFolder, over existing: SavedQueryFolder) -> SavedQueryFolder { + guard incoming.createdAt != existing.createdAt else { return incoming } + return SavedQueryFolder( + id: incoming.id, + name: incoming.name, + parentId: incoming.parentId, + connectionId: incoming.connectionId, + sortOrder: incoming.sortOrder, + createdAt: existing.createdAt, + updatedAt: incoming.updatedAt + ) + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift index a72e1492b6..f35d6c7982 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -35,7 +35,7 @@ struct ConnectionListView: View { /// still cancelled without a trace when a handler in the same update clears it, so only a cover /// that is on screen dismisses, and only one that is on screen can be waited on. @State private var isConnectionCoverOnScreen = false - @State private var importResultCount: Int? + @State private var importResult: MobileImportOutcomeMessage? @State private var actionErrorMessage: String? @State private var iCloudAccountAvailable = false @State private var tips = ConnectionListTips.makeGroup() @@ -208,8 +208,10 @@ struct ConnectionListView: View { .onChange(of: appState.connections) { _, _ in clearUnknownSelection() } - .alert(importResultMessage, isPresented: importResultPresented) { - Button(String(localized: "OK")) { importResultCount = nil } + .alert(importResult?.title ?? "", isPresented: importResultPresented, presenting: importResult) { _ in + Button(String(localized: "OK")) { importResult = nil } + } message: { result in + Text(result.message) } .alert( String(localized: "Sample Database Unavailable"), @@ -720,8 +722,8 @@ struct ConnectionListView: View { } } case .importFile(let url): - MobileConnectionImportSheet(fileURL: url) { count in - importResultCount = count + MobileConnectionImportSheet(fileURL: url) { outcome in + importResult = MobileImportOutcomeMessage(outcome) } .environment(appState) case .export: @@ -771,8 +773,8 @@ struct ConnectionListView: View { private var importResultPresented: Binding { Binding( - get: { importResultCount != nil }, - set: { if !$0 { importResultCount = nil } } + get: { importResult != nil }, + set: { if !$0 { importResult = nil } } ) } @@ -783,13 +785,6 @@ struct ConnectionListView: View { ) } - private var importResultMessage: String { - let count = importResultCount ?? 0 - return count == 1 - ? String(localized: "1 connection imported.") - : String(format: String(localized: "%d connections imported."), count) - } - private var refusal: AppError? { guard let id = presenter.refusedConnectionId, let connection = appState.connections.first(where: { $0.id == id }), diff --git a/apple/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift b/apple/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift index 781090e9e1..be2397bde6 100644 --- a/apple/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift +++ b/apple/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import TableProImport import TableProModels struct IdentifiableURL: Identifiable { @@ -13,6 +14,8 @@ struct MobileConnectionExportSheet: View { @Environment(\.dismiss) private var dismiss @State private var includePasswords = false + @State private var includeSavedQueries = true + @State private var includeGlobalSavedQueries = false @State private var passphrase = "" @State private var confirmPassphrase = "" @State private var error: String? @@ -25,6 +28,10 @@ struct MobileConnectionExportSheet: View { return !passphrase.isEmpty && passphrase == confirmPassphrase } + private var savedQueryCounts: SavedQueryCounts? { + IOSConnectionExportService.savedQueryCounts(for: connections, appState: appState) + } + var body: some View { NavigationStack { Form { @@ -33,6 +40,8 @@ struct MobileConnectionExportSheet: View { .foregroundStyle(.secondary) } + savedQueriesSection(savedQueryCounts) + Section { Toggle(String(localized: "Include passwords"), isOn: $includePasswords) } footer: { @@ -90,6 +99,47 @@ struct MobileConnectionExportSheet: View { } } + // Unreadable counts keep the toggle, so the export fails with a reason instead of dropping the queries. + private static func offersSavedQueries(_ counts: SavedQueryCounts?) -> Bool { + counts.map { $0.connectionScoped + $0.global > 0 } ?? true + } + + @ViewBuilder + private func savedQueriesSection(_ counts: SavedQueryCounts?) -> some View { + if Self.offersSavedQueries(counts) { + Section { + Toggle(String(localized: "Include Saved Queries"), isOn: $includeSavedQueries) + if let counts, counts.global > 0 { + Toggle(String(localized: "Also include global saved queries"), isOn: $includeGlobalSavedQueries) + .disabled(!includeSavedQueries) + } + } footer: { + if let counts { + Text(Self.savedQueriesFooter(counts)) + } + } + } + } + + private static func savedQueriesFooter(_ counts: SavedQueryCounts) -> String { + var sentences: [String] = [] + if counts.connectionScoped == 1 { + sentences.append(String(localized: "1 saved query that belongs to these connections, with its folder and keyword.")) + } else if counts.connectionScoped > 1 { + sentences.append(String( + format: String(localized: "%d saved queries that belong to these connections, with their folders and keywords."), + counts.connectionScoped + )) + } + if counts.global == 1 { + sentences.append(String(localized: "1 saved query that every connection shows.")) + } else if counts.global > 1 { + sentences.append(String(format: String(localized: "%d saved queries that every connection shows."), counts.global)) + } + sentences.append(String(localized: "Saved queries are written as readable text unless the file is encrypted.")) + return sentences.joined(separator: " ") + } + private var connectionCountLabel: String { connections.count == 1 ? String(localized: "1 connection will be exported.") @@ -99,10 +149,13 @@ struct MobileConnectionExportSheet: View { private func export() async { error = nil do { + let includesSavedQueries = Self.offersSavedQueries(savedQueryCounts) && includeSavedQueries let data = try await IOSConnectionExportService.exportData( connections: connections, appState: appState, includeCredentials: includePasswords, + includeSavedQueries: includesSavedQueries, + includeGlobalSavedQueries: includesSavedQueries && includeGlobalSavedQueries, passphrase: includePasswords ? passphrase : nil ) try Task.checkCancellation() diff --git a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift index 0c3b333b21..d3cce82ad9 100644 --- a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift +++ b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -4,7 +4,7 @@ import TableProModels struct MobileConnectionImportSheet: View { let fileURL: URL - var onImported: ((Int) -> Void)? + var onImported: ((ImportOutcome) -> Void)? @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss @@ -126,14 +126,29 @@ struct MobileConnectionImportSheet: View { connectionRow(row, in: review) } } footer: { - if !review.preview.collected.bundle.savedQueries.isEmpty { - Text("This file also holds saved queries, which import only on a Mac.") + if IOSConnectionImportService.leavesOutSavedQueries(review.preview) { + Text("Saved queries can't be read right now, so they are not imported.") } } + savedQueriesSection(review) } .disabled(isImporting) } + @ViewBuilder + private func savedQueriesSection(_ review: Review) -> some View { + if !review.preview.queries.isEmpty { + let statuses = review.plan.queryStatuses + Section("Saved Queries") { + ForEach(review.preview.queries) { row in + MobileImportQueryRow(row: row, status: statuses[row.ref]) { included in + self.review?.selection.setIncluded(included, query: row.ref) + } + } + } + } + } + private func connectionRow(_ row: ConnectionRow, in review: Review) -> some View { let isSelected = review.selection.resolution(for: row.ref) != nil return HStack(spacing: 12) { @@ -208,17 +223,6 @@ struct MobileConnectionImportSheet: View { return error.localizedDescription } - private static func message(for failure: ImportFailure) -> String { - switch failure { - case .libraryUnreadable: - String(localized: "TablePro could not read your connection library, so nothing was imported.") - case .connectionsNotSaved: - String(localized: "The connections could not be saved.") - case .savedQueriesNotSaved: - String(localized: "The saved queries could not be saved.") - } - } - // MARK: - State helpers private func setSelected(_ selected: Bool, _ row: ConnectionRow) { @@ -296,11 +300,11 @@ struct MobileConnectionImportSheet: View { appState: appState, secureStore: appState.secureStore ) - if let failure = outcome.failure, !outcome.importedAnything { - phase = .failed(Self.message(for: failure)) + if outcome.failure != nil, !outcome.importedAnything { + phase = .failed(MobileImportOutcomeMessage(outcome).message) return } - onImported?(outcome.connectionsAdded + outcome.connectionsReplaced) + onImported?(outcome) dismiss() } diff --git a/apple/TableProMobile/TableProMobile/Views/MobileImportQueryRow.swift b/apple/TableProMobile/TableProMobile/Views/MobileImportQueryRow.swift new file mode 100644 index 0000000000..b26e422d7a --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/MobileImportQueryRow.swift @@ -0,0 +1,84 @@ +import SwiftUI +import TableProImport + +struct MobileImportQueryRow: View { + let row: QueryRow + let status: QueryStatus? + let setIncluded: (Bool) -> Void + + var body: some View { + let isAvailable = status?.availability == .available + let isIncluded = status?.isIncluded ?? false + Button { + setIncluded(!isIncluded) + } label: { + HStack(spacing: 12) { + Image(systemName: isIncluded ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isIncluded ? Color.accentColor : Color.secondary) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(verbatim: row.name) + .foregroundStyle(isAvailable ? Color.primary : Color.secondary) + .lineLimit(1) + if status?.availability == .alreadySaved { + Text("already saved") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Group { + Text(verbatim: row.connectionName ?? String(localized: "All connections")) + if let keyword = row.keyword, isAvailable, status?.droppedKeyword == nil { + Text(verbatim: String(format: String(localized: "Keyword “%@”"), keyword)) + } + if !row.folderPath.isEmpty { + Text(verbatim: row.folderPath.joined(separator: " / ")) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let note = Self.note(for: row, status: status) { + Text(verbatim: note) + .font(.caption) + .foregroundStyle(.orange) + } + } + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!isAvailable) + .accessibilityAddTraits(isIncluded ? .isSelected : []) + } + + static func note(for row: QueryRow, status: QueryStatus?) -> String? { + guard let status else { return nil } + switch status.availability { + case .addedByAnotherRow: + return String(localized: "Another selected row adds the same query.") + case .connectionSkipped: + return String(localized: "Its connection is not imported.") + case .tooLarge: + let size = ByteCountFormatter.string(fromByteCount: Int64(row.byteCount), countStyle: .file) + return String(format: String(localized: "Too large to save (%@)"), size) + case .alreadySaved: + return nil + case .available: + break + } + switch status.droppedKeyword { + case .inUse(let keyword): + return String(format: String(localized: "Keyword “%@” is in use, so it imports without one."), keyword) + case .invalid(let keyword): + return String(format: String(localized: "Keyword “%@” is not valid, so it imports without one."), keyword) + case nil: + break + } + return status.nameExists ? String(localized: "A saved query with this name exists.") : nil + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 5a872d4f34..7832ebd8fe 100644 --- a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -36,6 +36,8 @@ struct QueryEditorView: View { @State private var hapticSuccess = false @State private var hapticError = false @State private var containerSize: CGSize = .zero + @State private var savedQueriesRoute: SavedQueriesSheetRoute? + @State private var savedQueryFolderExpansion: Set = [] private var session: ConnectionSession? { coordinator.session } private var tables: [TableInfo] { coordinator.tables } @@ -118,6 +120,13 @@ struct QueryEditorView: View { .sheet(isPresented: $showShareSheet) { ActivityViewController(items: [shareText]) } + .sheet(item: $savedQueriesRoute) { route in + SavedQueriesSheet( + route: route, + connectionId: connectionId, + expandedFolderIds: $savedQueryFolderExpansion + ) { coordinator.pendingQuery = SavedQueryDraft.editorText(opening: $0) } + } .confirmationDialog( String(localized: "Clear Query"), isPresented: $showClearConfirmation, @@ -478,6 +487,21 @@ struct QueryEditorView: View { private var queryMenu: some View { Menu { + Button { + savedQueriesRoute = .list + } label: { + Label("Saved Queries", systemImage: "list.star") + } + + Button { + savedQueriesRoute = SavedQueryDraft.savableSQL(from: query).map { SavedQueriesSheetRoute.save(sql: $0) } + } label: { + Label("Save Query…", systemImage: "star") + } + .disabled(!query.contains { !$0.isWhitespace } || appState.savedQueries.status != .ready) + + Divider() + Button { coordinator.selectedTab = .history } label: { diff --git a/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueriesSheet.swift b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueriesSheet.swift new file mode 100644 index 0000000000..2f4148594e --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueriesSheet.swift @@ -0,0 +1,94 @@ +import SwiftUI + +enum SavedQueriesSheetRoute: Identifiable, Hashable { + case list + case save(sql: String) + + var id: String { + switch self { + case .list: + "list" + case .save: + "save" + } + } +} + +struct SavedQueriesSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + let route: SavedQueriesSheetRoute + let connectionId: UUID + @Binding var expandedFolderIds: Set + let onOpen: (String) -> Void + + var body: some View { + NavigationStack { + switch appState.savedQueries.status { + case .ready: + content + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Saved Queries") + .navigationBarTitleDisplayMode(.inline) + .toolbar { closeItem } + case .failed: + unavailableView + } + } + } + + @ViewBuilder + private var content: some View { + switch route { + case .list: + SavedQueryListView(connectionId: connectionId, expandedFolderIds: $expandedFolderIds) { sql in + onOpen(sql) + dismiss() + } + case .save(let sql): + SavedQueryFormView(newQuery: sql, connectionId: connectionId) + } + } + + private var unavailableView: some View { + ContentUnavailableView { + Label("Saved Queries Unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text("TablePro could not read your saved queries. Nothing on this device has been changed.") + } actions: { + Button("Try Again") { + appState.savedQueries.retryLoadIfFailed() + } + .buttonStyle(.borderedProminent) + } + .navigationTitle("Saved Queries") + .navigationBarTitleDisplayMode(.inline) + .toolbar { closeItem } + } + + private var closeItem: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + CloseButton { dismiss() } + } + } +} + +extension View { + func savedQueryWriteFailureAlert(_ failure: Binding) -> some View { + alert( + failure.wrappedValue?.title ?? "", + isPresented: Binding( + get: { failure.wrappedValue != nil }, + set: { if !$0 { failure.wrappedValue = nil } } + ), + presenting: failure.wrappedValue + ) { _ in + Button("OK", role: .cancel) {} + } message: { presented in + Text(presented.message) + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryFolderNameForm.swift b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryFolderNameForm.swift new file mode 100644 index 0000000000..781e0a0c0b --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryFolderNameForm.swift @@ -0,0 +1,96 @@ +import SwiftUI +import TableProModels + +struct SavedQueryFolderNameForm: View { + enum Mode: Hashable { + case create(parentId: UUID?) + case rename(SavedQueryFolder) + } + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @FocusState private var isNameFocused: Bool + @State private var name: String + @State private var failure: SavedQueryWriteFailure? + + private let mode: Mode + private let connectionId: UUID + private let onCreated: (SavedQueryFolder) -> Void + + init(mode: Mode, connectionId: UUID, onCreated: @escaping (SavedQueryFolder) -> Void = { _ in }) { + self.mode = mode + self.connectionId = connectionId + self.onCreated = onCreated + _name = State(initialValue: Self.currentName(for: mode) ?? "") + } + + private static func currentName(for mode: Mode) -> String? { + guard case .rename(let folder) = mode else { return nil } + return folder.name + } + + private var isCreating: Bool { + switch mode { + case .create: + true + case .rename: + false + } + } + + private var acceptedName: String? { + SavedQueryDraft.acceptedName(name, replacing: Self.currentName(for: mode)) + } + + private var hasChanges: Bool { + name != (Self.currentName(for: mode) ?? "") + } + + var body: some View { + Form { + Section { + TextField("Name", text: $name) + .textInputAutocapitalization(.words) + .submitLabel(.done) + .focused($isNameFocused) + .onSubmit(save) + } + } + .navigationTitle(isCreating ? String(localized: "New Folder") : String(localized: "Rename Folder")) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .interactiveDismissDisabled(hasChanges) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + DiscardChangesCancelButton(hasChanges: hasChanges) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + ConfirmButton(title: "Save", action: save) + .disabled(acceptedName == nil) + } + } + .onAppear { isNameFocused = true } + .savedQueryWriteFailureAlert($failure) + } + + private func save() { + guard let accepted = acceptedName else { return } + let store = appState.savedQueries + switch mode { + case .create(let parentId): + let folder = SavedQueryFolder(name: accepted, parentId: parentId, connectionId: connectionId) + guard store.insertFolder(folder) else { + failure = .notSaved + return + } + onCreated(folder) + case .rename(let folder): + let renamed = store.updateFolder(folder.id) { $0.name = accepted } + guard renamed else { + failure = .failedFolderWrite(folder.id, in: store.contents.folders) + return + } + } + dismiss() + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryFolderPicker.swift b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryFolderPicker.swift new file mode 100644 index 0000000000..72bf6d7ddc --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryFolderPicker.swift @@ -0,0 +1,91 @@ +import SwiftUI +import TableProConnectionLibrary +import TableProImport +import TableProModels + +struct SavedQueryFolderPicker: View { + @Environment(\.dismiss) private var dismiss + + let folders: [SavedQueryFolder] + let selection: UUID? + let recordScope: UUID? + let title: String + let onPick: (UUID?) -> Void + + var body: some View { + let rows = SavedQueryOutline.folderRows(folders) + List { + Section { + destinationRow( + name: String(localized: "None"), + systemImage: "tray", + depth: 0, + folderId: nil, + isGlobal: false, + canHold: true + ) + } + if !rows.isEmpty { + Section { + ForEach(rows) { row in + destinationRow( + name: row.folder.name, + systemImage: "folder", + depth: row.depth, + folderId: row.folder.id, + isGlobal: row.folder.connectionId == nil, + canHold: SavedQueryScopeRule.folder(row.folder.connectionId, canHold: recordScope) + ) + } + } footer: { + if SavedQueryDraft.hidesFolders(folders, forScope: recordScope) { + Text("Only folders available in all connections can hold a global query.") + } + } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } + + // A folder that cannot hold the query stays in place, dimmed, so the nesting around it still reads. + private func destinationRow( + name: String, + systemImage: String, + depth: Int, + folderId: UUID?, + isGlobal: Bool, + canHold: Bool + ) -> some View { + let isCurrent = selection == folderId + return Button { + onPick(folderId) + dismiss() + } label: { + HStack(spacing: 10) { + Image(systemName: systemImage) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(verbatim: name) + .foregroundStyle(canHold ? Color.primary : Color.secondary) + Spacer() + if isGlobal { + Image(systemName: "globe") + .font(.caption2) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + if isCurrent { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + } + .padding(.leading, CGFloat(depth) * 20) + .contentShape(Rectangle()) + } + .disabled(!canHold) + .accessibilityLabel(isGlobal ? name + ", " + String(localized: "global") : name) + .accessibilityAddTraits(isCurrent ? .isSelected : []) + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryFormView.swift b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryFormView.swift new file mode 100644 index 0000000000..e11eda2f36 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryFormView.swift @@ -0,0 +1,169 @@ +import SwiftUI +import TableProModels + +struct SavedQueryFormView: View { + private enum Mode { + case new(sql: String) + case edit(SavedQuery) + } + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var draft: SavedQueryDraft + @State private var failure: SavedQueryWriteFailure? + + private let mode: Mode + private let connectionId: UUID + private let opening: SavedQueryDraft + + // Seeded here, not in onAppear, so the opening scope never reaches onChange and drops the folder. + init(newQuery sql: String, connectionId: UUID) { + let opening = SavedQueryDraft(saving: sql) + mode = .new(sql: sql) + self.connectionId = connectionId + self.opening = opening + _draft = State(initialValue: opening) + } + + init(editing query: SavedQuery, connectionId: UUID) { + let opening = SavedQueryDraft(query: query) + mode = .edit(query) + self.connectionId = connectionId + self.opening = opening + _draft = State(initialValue: opening) + } + + private var sql: String { + switch mode { + case .new(let text): + text + case .edit(let query): + query.query + } + } + + private var editingId: UUID? { + guard case .edit(let query) = mode else { return nil } + return query.id + } + + private var hasChanges: Bool { + draft != opening + } + + var body: some View { + let store = appState.savedQueries + let folders = store.contents(visibleTo: connectionId).folders + let scope = draft.scope(in: connectionId) + let validation = currentValidation(in: store) + Form { + Section { + TextField("Name", text: $draft.name) + .submitLabel(.done) + if !folders.isEmpty || draft.folderId != nil { + NavigationLink { + SavedQueryFolderPicker( + folders: folders, + selection: draft.folderId, + recordScope: scope, + title: String(localized: "Folder") + ) { draft.folderId = $0 } + } label: { + LabeledContent("Folder") { + Text(verbatim: folderName(in: store.contents.folders)) + } + } + } + } footer: { + if SavedQueryDraft.hidesFolders(folders, forScope: scope) { + Text("Only folders available in all connections can hold a global query.") + } + } + + Section { + TextField("Keyword", text: $draft.keyword) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .fontDesign(.monospaced) + Toggle(isOn: $draft.isGlobal) { + Text("Global") + Text("Available in all connections") + } + } footer: { + if let message = validation.keywordCheck.message { + Text(verbatim: message) + .foregroundStyle(validation.keywordCheck.blocksSave ? Color.red : Color.orange) + } + } + + if validation.exceedsSizeLimit { + Section { + Text(verbatim: SavedQueryDraft.sizeLimitMessage) + .foregroundStyle(.red) + } + } + } + .navigationTitle(editingId == nil ? String(localized: "Save Query") : String(localized: "Edit Query")) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .interactiveDismissDisabled(hasChanges) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + DiscardChangesCancelButton(hasChanges: hasChanges) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + ConfirmButton(title: "Save") { save() } + .disabled(!draft.canSave(sql: sql, validation: validation)) + } + } + .onChange(of: draft.isGlobal) { + draft = draft.resolvingFolder(among: appState.savedQueries.contents.folders, connectionId: connectionId) + } + .savedQueryWriteFailureAlert($failure) + } + + private func folderName(in folders: [SavedQueryFolder]) -> String { + guard let folderId = draft.folderId, let folder = folders.first(where: { $0.id == folderId }) else { + return String(localized: "None") + } + return folder.name + } + + private func currentValidation(in store: SavedQueryLibrary) -> SavedQueryValidation { + let editingId = editingId + return draft.validation( + sql: sql, + savingOver: editingId.flatMap { store.contents.query($0) }, + changedSince: opening, + connectionId: connectionId, + folders: store.contents.folders + ) { store.isKeywordAvailable($0, connectionId: $1, excluding: editingId) } + } + + private func save() { + let store = appState.savedQueries + guard draft.canSave(sql: sql, validation: currentValidation(in: store)) else { return } + switch mode { + case .new(let text): + guard store.insert(draft.newQuery(sql: text, connectionId: connectionId)) else { + failure = .notSaved + return + } + case .edit(let query): + let edits = draft + let openingEdits = opening + let ownerId = connectionId + let folders = store.contents.folders + let updated = store.update(query.id) { live in + edits.apply(to: &live, changedSince: openingEdits, connectionId: ownerId, folders: folders) + } + // The form stays open on a deleted query, so the typed name and keyword are not lost. + guard updated else { + failure = .failedQueryWrite(query.id, in: store.contents.queries) + return + } + } + dismiss() + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryListView.swift b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryListView.swift new file mode 100644 index 0000000000..85d0e757b4 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryListView.swift @@ -0,0 +1,351 @@ +import SwiftUI +import TableProConnectionLibrary +import TableProModels + +// Routes carry the row as the user saw it, so a form stays open when a pull deletes the record. +enum SavedQueryListRoute: Hashable { + case edit(SavedQuery) + case move(SavedQuery) + case newFolder(parentId: UUID?) + case renameFolder(SavedQueryFolder) +} + +struct SavedQueryListView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + let connectionId: UUID + @Binding var expandedFolderIds: Set + let onOpen: (String) -> Void + + @State private var searchText = "" + @State private var route: SavedQueryListRoute? + @State private var renamingQueryId: UUID? + @State private var queryPendingDeletion: SavedQuery? + @State private var folderPendingDeletion: SavedQueryFolder? + @State private var failure: SavedQueryWriteFailure? + + var body: some View { + let visible = appState.savedQueries.contents(visibleTo: connectionId) + let outline = SavedQueryOutline.build(folders: visible.folders, queries: visible.queries) + let shown = SavedQueryOutline.filter(outline, matching: searchText) + let isSearching = !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasFolders = !visible.folders.isEmpty + List { + SavedQueryTreeLevel( + nodes: shown, + isExpanded: { folderExpansion($0, isSearching: isSearching) }, + queryRow: { queryRow($0, canMove: hasFolders || $0.folderId != nil) }, + folderLabel: { folderRow($0) } + ) + } + .listStyle(.insetGrouped) + .overlay { + if outline.isEmpty { + ContentUnavailableView( + "No Saved Queries", + systemImage: "star", + description: Text("Queries you save from the Query menu appear here.") + ) + } else if shown.isEmpty { + ContentUnavailableView.search(text: searchText) + } + } + .searchable(text: $searchText, prompt: Text("Search Saved Queries")) + .navigationTitle("Saved Queries") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + route = .newFolder(parentId: nil) + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + CloseButton { dismiss() } + } + } + .navigationDestination(item: $route) { destination($0) } + .confirmationDialog( + String(localized: "Delete Saved Query?"), + isPresented: queryDeletionPresented, + titleVisibility: .visible, + presenting: queryPendingDeletion + ) { query in + Button(String(localized: "Delete"), role: .destructive) { + delete(query) + } + } message: { query in + Text(String(format: String(localized: "\"%@\" will be permanently deleted."), query.name)) + } + .confirmationDialog( + String(localized: "Delete Folder?"), + isPresented: folderDeletionPresented, + titleVisibility: .visible, + presenting: folderPendingDeletion + ) { folder in + Button(String(localized: "Delete"), role: .destructive) { + delete(folder) + } + } message: { folder in + Text(String( + format: String(localized: "The folder \"%@\" will be deleted. Items inside will be moved to the parent level."), + folder.name + )) + } + .savedQueryWriteFailureAlert($failure) + } + + // MARK: - Rows + + private func queryRow(_ query: SavedQuery, canMove: Bool) -> some View { + SavedQueryRow( + query: query, + isRenaming: renamingQueryId == query.id, + onOpen: { onOpen(query.query) }, + onCommitRename: { commitRename(query, to: $0) }, + onCancelRename: { renamingQueryId = nil } + ) + .swipeActions(edge: .leading) { + Button { + route = .edit(query) + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + queryPendingDeletion = query + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + .contextMenu { + queryMenu(query, canMove: canMove) + } + .renameAction { + renamingQueryId = query.id + } + .accessibilityAction(named: Text("Delete")) { + queryPendingDeletion = query + } + } + + @ViewBuilder + private func queryMenu(_ query: SavedQuery, canMove: Bool) -> some View { + Button { + onOpen(query.query) + } label: { + Label("Open in Editor", systemImage: "arrow.right.circle") + } + Button { + ClipboardExporter.copyToClipboard(query.query) + } label: { + Label("Copy Query", systemImage: "doc.on.doc") + } + Divider() + RenameButton() + Button { + route = .edit(query) + } label: { + Label("Edit…", systemImage: "pencil") + } + if canMove { + Button { + route = .move(query) + } label: { + Label("Move to Folder…", systemImage: "folder") + } + } + Divider() + Button(role: .destructive) { + queryPendingDeletion = query + } label: { + Label("Delete", systemImage: "trash") + } + } + + private func folderRow(_ folder: SavedQueryFolder) -> some View { + SavedQueryFolderLabel(folder: folder) + .contentShape(Rectangle()) + .swipeActions(edge: .leading) { + Button { + route = .renameFolder(folder) + } label: { + Label("Rename", systemImage: "pencil") + } + .tint(.blue) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + folderPendingDeletion = folder + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + .contextMenu { + folderMenu(folder) + } + .renameAction { + route = .renameFolder(folder) + } + .accessibilityAction(named: Text("Delete Folder")) { + folderPendingDeletion = folder + } + } + + @ViewBuilder + private func folderMenu(_ folder: SavedQueryFolder) -> some View { + Button { + route = .newFolder(parentId: folder.id) + } label: { + Label("New Subfolder", systemImage: "folder.badge.plus") + } + Divider() + RenameButton() + Toggle(isOn: Binding( + get: { folder.connectionId == nil }, + set: { setGlobal(folder, $0) } + )) { + Label("Global", systemImage: "globe") + } + Divider() + Button(role: .destructive) { + folderPendingDeletion = folder + } label: { + Label("Delete Folder", systemImage: "trash") + } + } + + @ViewBuilder + private func destination(_ target: SavedQueryListRoute) -> some View { + switch target { + case .edit(let query): + SavedQueryFormView(editing: query, connectionId: connectionId) + case .move(let query): + SavedQueryFolderPicker( + folders: appState.savedQueries.contents(visibleTo: connectionId).folders, + selection: query.folderId, + recordScope: query.connectionId, + title: String(localized: "Move to Folder") + ) { move(query, to: $0) } + case .newFolder(let parentId): + SavedQueryFolderNameForm(mode: .create(parentId: parentId), connectionId: connectionId) { _ in + if let parentId { + expandedFolderIds.insert(parentId) + } + } + case .renameFolder(let folder): + SavedQueryFolderNameForm(mode: .rename(folder), connectionId: connectionId) + } + } + + // MARK: - Edits + + private func commitRename(_ query: SavedQuery, to proposed: String) { + guard renamingQueryId == query.id else { return } + renamingQueryId = nil + guard let name = SavedQueryDraft.renamedName(proposed, of: query) else { return } + let store = appState.savedQueries + let renamed = store.update(query.id) { $0.name = name } + if !renamed { + failure = .failedQueryWrite(query.id, in: store.contents.queries) + } + } + + private func move(_ query: SavedQuery, to folderId: UUID?) { + guard folderId != query.folderId else { return } + let store = appState.savedQueries + let folder = folderId.flatMap { store.contents.folder($0) } + guard folderId == nil || folder != nil else { + failure = .folderDeleted + return + } + let moved = store.update(query.id) { SavedQueryDraft.move(&$0, to: folder) } + if !moved { + failure = .failedQueryWrite(query.id, in: store.contents.queries) + } + } + + private func setGlobal(_ folder: SavedQueryFolder, _ isGlobal: Bool) { + let store = appState.savedQueries + let scope = isGlobal ? nil : connectionId + let updated = store.updateFolder(folder.id) { $0.connectionId = scope } + if !updated { + failure = .failedFolderWrite(folder.id, in: store.contents.folders) + } + } + + private func delete(_ query: SavedQuery) { + let store = appState.savedQueries + if !store.deleteQuery(query.id) { + failure = .failedQueryWrite(query.id, in: store.contents.queries) + } + } + + private func delete(_ folder: SavedQueryFolder) { + let store = appState.savedQueries + if !store.deleteFolder(folder.id) { + failure = .failedFolderWrite(folder.id, in: store.contents.folders) + } + } + + // MARK: - Bindings + + // Folders holding a match open while a search runs, the way the connection list opens its groups. + private func folderExpansion(_ folderId: UUID, isSearching: Bool) -> Binding { + Binding( + get: { isSearching || expandedFolderIds.contains(folderId) }, + set: { isExpanded in + if isExpanded { + expandedFolderIds.insert(folderId) + } else { + expandedFolderIds.remove(folderId) + } + } + ) + } + + private var queryDeletionPresented: Binding { + Binding( + get: { queryPendingDeletion != nil }, + set: { if !$0 { queryPendingDeletion = nil } } + ) + } + + private var folderDeletionPresented: Binding { + Binding( + get: { folderPendingDeletion != nil }, + set: { if !$0 { folderPendingDeletion = nil } } + ) + } +} + +private struct SavedQueryTreeLevel: View { + let nodes: [SavedQueryOutlineNode] + let isExpanded: (UUID) -> Binding + let queryRow: (SavedQuery) -> QueryRow + let folderLabel: (SavedQueryFolder) -> FolderLabel + + var body: some View { + ForEach(nodes) { node in + switch node { + case .folder(let folder, let children): + DisclosureGroup(isExpanded: isExpanded(folder.id)) { + SavedQueryTreeLevel( + nodes: children, + isExpanded: isExpanded, + queryRow: queryRow, + folderLabel: folderLabel + ) + } label: { + folderLabel(folder) + } + case .query(let query): + queryRow(query) + } + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryRow.swift b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryRow.swift new file mode 100644 index 0000000000..e77590eab0 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/SavedQueries/SavedQueryRow.swift @@ -0,0 +1,132 @@ +import SwiftUI +import TableProModels + +struct SavedQueryRow: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + let query: SavedQuery + let isRenaming: Bool + let onOpen: () -> Void + let onCommitRename: (String) -> Void + let onCancelRename: () -> Void + + @State private var draftName = "" + @FocusState private var isNameFieldFocused: Bool + + var body: some View { + if isRenaming { + content + } else { + Button(action: onOpen) { + content + .contentShape(Rectangle()) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Self.accessibilityLabel(for: query)) + .accessibilityHint(Text("Replaces the editor's text with this query.")) + } + } + + static func accessibilityLabel(for query: SavedQuery) -> String { + var parts = [query.name] + if query.connectionId == nil { + parts.append(String(localized: "global")) + } + if let keyword = query.keyword, !keyword.isEmpty { + parts.append(String(format: String(localized: "keyword: %@"), keyword)) + } + return parts.joined(separator: ", ") + } + + private var lineLimit: Int? { + dynamicTypeSize.isAccessibilitySize ? nil : 1 + } + + private var content: some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + title + if let preview = SavedQueryPreview.firstLine(of: query.query) { + Text(verbatim: preview) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + if query.connectionId == nil { + Image(systemName: "globe") + .font(.caption2) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + + if let keyword = query.keyword, !keyword.isEmpty { + Text(verbatim: keyword) + .font(.system(.caption, design: .monospaced).weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Capsule().fill(.quaternary)) + .accessibilityHidden(true) + } + } + } + + @ViewBuilder + private var title: some View { + if isRenaming { + TextField("Name", text: $draftName) + .font(.body) + .focused($isNameFieldFocused) + .submitLabel(.done) + .onSubmit { onCommitRename(draftName) } + .onKeyPress(.escape) { + onCancelRename() + return .handled + } + .onAppear { + draftName = query.name + isNameFieldFocused = true + } + .onChange(of: isNameFieldFocused) { _, focused in + guard !focused else { return } + onCommitRename(draftName) + } + } else { + Text(verbatim: query.name) + .font(.body) + .foregroundStyle(.primary) + .lineLimit(lineLimit) + } + } +} + +struct SavedQueryFolderLabel: View { + let folder: SavedQueryFolder + + private var isGlobal: Bool { + folder.connectionId == nil + } + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(verbatim: folder.name) + .lineLimit(1) + Spacer() + if isGlobal { + Image(systemName: "globe") + .font(.caption2) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(isGlobal ? folder.name + ", " + String(localized: "global") : folder.name) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/DeletedConnectionPurgeTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/DeletedConnectionPurgeTests.swift index 9da62e7aa0..712d7f3295 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/DeletedConnectionPurgeTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/DeletedConnectionPurgeTests.swift @@ -1,6 +1,7 @@ import Foundation @testable import TableProMobile import TableProModels +import TableProSyncTransport import Testing @MainActor @@ -80,6 +81,59 @@ struct DeletedConnectionPurgeTests { ]) } + @Test("Deleting a connection deletes its saved queries and folders in iCloud and pushes a global query it held") + func localDeleteTombstonesSavedQueries() { + let state = makeLibrary() + let folder = SavedQueryFolder(name: "Reports", connectionId: deleted.id) + let scoped = SavedQuery(name: "Scoped", query: "SELECT 1", folderId: folder.id, connectionId: deleted.id) + let global = SavedQuery(name: "Global", query: "SELECT 2", folderId: folder.id) + let other = SavedQuery(name: "Other", query: "SELECT 3", connectionId: kept.id) + #expect(state.savedQueries.insertFolder(folder)) + #expect(state.savedQueries.insert(scoped)) + #expect(state.savedQueries.insert(global)) + #expect(state.savedQueries.insert(other)) + fixture.metadata.clearDirty(type: .favorite) + + state.removeConnections([deleted.id]) + + let remaining = state.savedQueries.contents + let remainingIds = remaining.queries.map(\.id) + let queryTombstones = fixture.metadata.tombstones(for: .favorite).map(\.id) + let folderTombstones = fixture.metadata.tombstones(for: .favoriteFolder).map(\.id) + #expect(remainingIds == [global.id, other.id]) + #expect(remaining.query(global.id)?.folderId == nil) + #expect(remaining.folders.isEmpty) + #expect(queryTombstones == [scoped.id.uuidString]) + #expect(folderTombstones == [folder.id.uuidString]) + #expect(fixture.metadata.dirtyIds(for: .favorite) == [global.id.uuidString]) + } + + @Test("A connection deleted on another device takes its saved queries with it and leaves nothing to push") + func syncedDeleteRemovesSavedQueriesQuietly() { + let state = makeLibrary() + let scoped = SavedQuery(name: "Scoped", query: "SELECT 1", connectionId: deleted.id) + #expect(state.savedQueries.insert(scoped)) + + state.applySyncedConnections(state.connections.filter { $0.id != deleted.id }) + + #expect(state.savedQueries.contents.queries.isEmpty) + #expect(fixture.metadata.tombstones(for: .favorite).isEmpty) + } + + @Test("Deleting the sample removes its saved queries and leaves nothing to push") + func sampleDeleteRemovesSavedQueriesQuietly() throws { + let state = fixture.makeState(syncEnabled: false) + let sampleId = try state.openSampleDatabase() + let scoped = SavedQuery(name: "Sample", query: "SELECT 1", connectionId: sampleId) + #expect(state.savedQueries.insert(scoped)) + #expect(!fixture.metadata.dirtyIds(for: .favorite).contains(scoped.id.uuidString)) + + state.removeConnections([sampleId]) + + #expect(state.savedQueries.contents.queries.isEmpty) + #expect(fixture.metadata.tombstones(for: .favorite).isEmpty) + } + @Test("A sync merge that deletes nothing leaves every history alone") func syncWithoutDeletionKeepsHistory() throws { let state = makeLibrary() diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift index 2c436875e1..0f8888245b 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift @@ -18,11 +18,18 @@ struct IOSConnectionExportServiceTests { fixture.makeState(syncEnabled: false, secureStore: secureStore) } - private func exportedBundle(_ connections: [DatabaseConnection], from state: AppState) async throws -> ConnectionBundle { + private func exportedBundle( + _ connections: [DatabaseConnection], + from state: AppState, + includeSavedQueries: Bool = true, + includeGlobalSavedQueries: Bool = false + ) async throws -> ConnectionBundle { let data = try await IOSConnectionExportService.exportData( connections: connections, appState: state, includeCredentials: false, + includeSavedQueries: includeSavedQueries, + includeGlobalSavedQueries: includeGlobalSavedQueries, passphrase: nil ) return try ConnectionBundleCodec.decode(data) @@ -85,6 +92,61 @@ struct IOSConnectionExportServiceTests { #expect(!object.keys.contains("queryFolders")) } + @Test("An export carries the exported connections' saved queries with their folders, and global ones only on request") + func exportCarriesSavedQueries() async throws { + let state = makeState() + let orders = DatabaseConnection(name: "Orders", type: .postgresql, host: "db.example.com") + let other = DatabaseConnection(name: "Other", type: .postgresql, host: "other.example.com") + #expect(state.addConnection(orders)) + #expect(state.addConnection(other)) + let reports = SavedQueryFolder(name: "Reports", connectionId: orders.id) + #expect(state.savedQueries.insertFolder(reports)) + #expect(state.savedQueries.insert(SavedQuery( + name: "Daily active users", query: "select 1", keyword: "dau", folderId: reports.id, connectionId: orders.id + ))) + #expect(state.savedQueries.insert(SavedQuery(name: "Locks", query: "select 2"))) + #expect(state.savedQueries.insert(SavedQuery(name: "Other only", query: "select 3", connectionId: other.id))) + + #expect( + IOSConnectionExportService.savedQueryCounts(for: [orders], appState: state) + == SavedQueryCounts(connectionScoped: 1, global: 1) + ) + + let scoped = try await exportedBundle([orders], from: state) + let query = try #require(scoped.savedQueries.first) + #expect(scoped.savedQueries.map(\.name) == ["Daily active users"]) + #expect(query.sql == "select 1") + #expect(query.keyword == "dau") + #expect(query.connectionRef == scoped.connections.first?.ref) + #expect(scoped.folderChain(query.folderRef).map(\.name) == ["Reports"]) + + let withGlobal = try await exportedBundle([orders], from: state, includeGlobalSavedQueries: true) + #expect(Set(withGlobal.savedQueries.map(\.name)) == ["Daily active users", "Locks"]) + #expect(withGlobal.savedQueries.first { $0.name == "Locks" }?.connectionRef == nil) + + let without = try await exportedBundle([orders], from: state, includeSavedQueries: false, includeGlobalSavedQueries: true) + #expect(without.savedQueries.isEmpty) + #expect(without.queryFolders.isEmpty) + } + + @Test("A saved-query store that failed to load refuses an export that includes them, and exports without them") + func unreadableSavedQueriesRefuseExport() async throws { + let unreadable = Data("{ not json".utf8) + let savedQueriesFile = fixture.libraryDirectory.appendingPathComponent("saved-queries.json") + try unreadable.write(to: savedQueriesFile) + let state = makeState() + let connection = DatabaseConnection(name: "Orders", type: .postgresql, host: "db.example.com") + #expect(state.addConnection(connection)) + + #expect(IOSConnectionExportService.savedQueryCounts(for: [connection], appState: state) == nil) + await #expect(throws: IOSConnectionExportError.savedQueriesUnreadable) { + try await exportedBundle([connection], from: state) + } + let bundle = try await exportedBundle([connection], from: state, includeSavedQueries: false) + #expect(bundle.connections.count == 1) + #expect(try Data(contentsOf: savedQueriesFile) == unreadable) + } + @Test("SSL modes export in the spelling the Mac reads") func exportWritesCanonicalSSLModes() async throws { let cases: [(mode: SSLConfiguration.SSLMode, expected: String)] = [ diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift index 4011ec5334..90cef586ff 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -85,11 +85,11 @@ struct IOSConnectionImportServiceTests { #expect(!recognized.contains("Vertica")) } - @Test("iPhone imports nested groups to the library depth and no saved queries or credential profiles") + @Test("iPhone imports nested groups to the library depth, saved queries while their store is readable, and no credential profiles") func environmentRules() { - let rules = IOSConnectionImportService.environment().rules + let rules = IOSConnectionImportService.environment(for: appState).rules #expect(rules.maximumGroupDepth == LibraryGroupGraph.maxNestingDepth) - #expect(!rules.supportsSavedQueries) + #expect(rules.supportsSavedQueries) #expect(!rules.supportsCredentialProfiles) } @@ -124,8 +124,8 @@ struct IOSConnectionImportServiceTests { #expect(connection.tagIds.compactMap { appState.tag(for: $0)?.name } == ["production"]) } - @Test("Saved queries in a version 2 file are left out and the connection still imports") - func savedQueriesAreIgnored() async throws { + @Test("Saved queries in a version 2 file import with their connection, folder and keyword") + func savedQueriesImport() async throws { var builder = ConnectionBundleBuilder(appVersion: "Tests") builder.addConnection(settings(), ref: "c1") builder.addSavedQuery( @@ -138,13 +138,145 @@ struct IOSConnectionImportServiceTests { builder.addSavedQuery(name: "Locks", sql: "select 2", keyword: nil, connection: nil) let preview = try await analyzed(try builder.build()) - #expect(preview.queries.isEmpty) - #expect(preview.connections.map(\.savedQueryCount) == [0]) + #expect(preview.queries.map(\.name) == ["Daily active users", "Locks"]) + #expect(preview.connections.map(\.savedQueryCount) == [1]) let outcome = await apply(preview) #expect(outcome.connectionsAdded == 1) - #expect(outcome.savedQueriesAdded == 0) + #expect(outcome.savedQueriesAdded == 2) #expect(outcome.savedQueriesNotImported == 0) + + let connection = try #require(appState.connections.first) + let contents = appState.savedQueries.contents + let scoped = try #require(contents.queries.first { $0.name == "Daily active users" }) + #expect(scoped.query == "select 1") + #expect(scoped.keyword == "dau") + #expect(scoped.connectionId == connection.id) + let folder = try #require(contents.folders.first { $0.id == scoped.folderId }) + #expect(folder.name == "Reports") + #expect(folder.connectionId == connection.id) + let global = try #require(contents.queries.first { $0.name == "Locks" }) + #expect(global.connectionId == nil) + #expect(global.folderId == nil) + } + + @Test("A file imported again with Keep Existing finds its queries already saved and adds none") + func keepExistingAgainAddsNothing() async throws { + let existing = ordersConnection() + #expect(appState.addConnection(existing)) + var builder = ConnectionBundleBuilder(appVersion: "Tests") + builder.addConnection(settings(), ref: "c1") + let queryRef = builder.addSavedQuery(name: "Daily active users", sql: "select 1", keyword: "dau", connection: "c1") + let bundle = try builder.build() + + let first = try keepingExisting(try await analyzed(bundle), existing) + let firstOutcome = await IOSConnectionImportService.apply(first, appState: appState, secureStore: store) + #expect(firstOutcome.savedQueriesAdded == 1) + #expect(MobileImportOutcomeMessage(firstOutcome).message == String(localized: "1 saved query was added.")) + + let again = try keepingExisting(try await analyzed(bundle), existing) + #expect(again.queryStatuses[queryRef]?.availability == .alreadySaved) + #expect(again.queries.isEmpty) + #expect(appState.savedQueries.contents.queries.map(\.connectionId) == [existing.id]) + } + + @Test("A keyword the connection already holds is dropped with a note, and the query still imports") + func heldKeywordIsDropped() async throws { + let existing = ordersConnection() + #expect(appState.addConnection(existing)) + #expect(appState.savedQueries.insert(SavedQuery(name: "Sessions", query: "select 3", keyword: "dau", connectionId: existing.id))) + var builder = ConnectionBundleBuilder(appVersion: "Tests") + builder.addConnection(settings(), ref: "c1") + let queryRef = builder.addSavedQuery(name: "Daily active users", sql: "select 1", keyword: "dau", connection: "c1") + let preview = try await analyzed(try builder.build()) + + let plan = try keepingExisting(preview, existing) + let status = try #require(plan.queryStatuses[queryRef]) + let row = try #require(preview.queryRow(queryRef)) + #expect(status.droppedKeyword == .inUse("dau")) + #expect(MobileImportQueryRow.note(for: row, status: status) == String( + format: String(localized: "Keyword “%@” is in use, so it imports without one."), + "dau" + )) + + let outcome = await IOSConnectionImportService.apply(plan, appState: appState, secureStore: store) + #expect(outcome.savedQueriesAdded == 1) + let imported = try #require(appState.savedQueries.contents.queries.first { $0.name == "Daily active users" }) + #expect(imported.keyword == nil) + #expect(imported.connectionId == existing.id) + } + + @Test("A query saved after the review is matched when the import writes, not added twice") + func writeDecidesAgainstLiveRows() async throws { + var builder = ConnectionBundleBuilder(appVersion: "Tests") + builder.addConnection(settings(), ref: "c1") + builder.addSavedQuery(name: "Locks", sql: "select 2", keyword: nil, connection: nil) + let preview = try await analyzed(try builder.build()) + let plan = ImportPlanner.plan(preview, selection: .defaults(for: preview)) + #expect(plan.queries.count == 1) + #expect(appState.savedQueries.insert(SavedQuery(name: "Locks", query: "select 2"))) + + let outcome = await IOSConnectionImportService.apply(plan, appState: appState, secureStore: store) + + #expect(outcome.connectionsAdded == 1) + #expect(outcome.savedQueriesAdded == 0) + #expect(outcome.savedQueriesNotImported == 1) + #expect(appState.savedQueries.contents.queries.count(where: { $0.name == "Locks" }) == 1) + } + + @Test("A saved-query store that failed to load leaves the file's queries out, says so, and keeps its file") + func unreadableSavedQueriesAreLeftOut() async throws { + let failedFixture = try AppStateFixture() + let unreadable = Data("{ not json".utf8) + let savedQueriesFile = failedFixture.libraryDirectory.appendingPathComponent("saved-queries.json") + try unreadable.write(to: savedQueriesFile) + let failed = failedFixture.makeState(syncEnabled: false, secureStore: store) + var builder = ConnectionBundleBuilder(appVersion: "Tests") + builder.addConnection(settings(), ref: "c1") + builder.addSavedQuery(name: "Locks", sql: "select 2", keyword: nil, connection: "c1") + + let preview = try await IOSConnectionImportService.preview( + of: try builder.build(), + fileName: "Tests.tablepro", + appState: failed + ) + #expect(!preview.environment.rules.supportsSavedQueries) + #expect(preview.queries.isEmpty) + #expect(IOSConnectionImportService.leavesOutSavedQueries(preview)) + + let plan = ImportPlanner.plan(preview, selection: .defaults(for: preview)) + let outcome = await IOSConnectionImportService.apply(plan, appState: failed, secureStore: store) + #expect(outcome.connectionsAdded == 1) + #expect(outcome.savedQueriesAdded == 0) + #expect(try Data(contentsOf: savedQueriesFile) == unreadable) + } + + @Test("A query row explains why it does not import as the Mac review does") + func queryRowNotes() { + let row = QueryRow( + ref: "q1", name: "Locks", keyword: "locks", connection: nil, connectionName: nil, + folderPath: [], byteCount: 1_000_000, isTooLarge: true, isSuggested: true + ) + func note(_ availability: QueryStatus.Availability, dropped: SavedQueryKeywordDrop? = nil, nameExists: Bool = false) -> String? { + MobileImportQueryRow.note(for: row, status: QueryStatus( + availability: availability, + isIncluded: availability == .available, + nameExists: nameExists, + droppedKeyword: dropped + )) + } + + #expect(note(.addedByAnotherRow) == String(localized: "Another selected row adds the same query.")) + #expect(note(.connectionSkipped) == String(localized: "Its connection is not imported.")) + let size = ByteCountFormatter.string(fromByteCount: 1_000_000, countStyle: .file) + #expect(note(.tooLarge) == String(format: String(localized: "Too large to save (%@)"), size)) + #expect(note(.alreadySaved) == nil) + #expect(note(.available, dropped: .invalid("a b")) == String( + format: String(localized: "Keyword “%@” is not valid, so it imports without one."), + "a b" + )) + #expect(note(.available, nameExists: true) == String(localized: "A saved query with this name exists.")) + #expect(note(.available) == nil) } @Test("A nested group path imports as nested groups") @@ -233,25 +365,42 @@ struct IOSConnectionImportServiceTests { // MARK: - Duplicates - @Test("A duplicate offers As Copy and Replace, and no Keep Existing without saved queries") + @Test("A duplicate offers As Copy and Replace, and Keep Existing first when the file holds saved queries for it") func duplicateOffersCopyAndReplace() async throws { - let existing = DatabaseConnection( - name: "Orders", - type: .postgresql, - host: "db.example.com", - port: 5_432, - username: "app", - database: "orders" - ) + let existing = ordersConnection() #expect(appState.addConnection(existing)) let bundle = try ConnectionBundle(appVersion: "Tests", connections: [BundleConnection(ref: "c1", settings: settings())]) + var builder = ConnectionBundleBuilder(appVersion: "Tests") + builder.addConnection(settings(), ref: "c1") + builder.addSavedQuery(name: "Locks", sql: "select 2", keyword: nil, connection: "c1") let rows = try await analyzed(bundle).connections let row = try #require(rows.first) + let rowsWithQueries = try await analyzed(try builder.build()).connections + let rowWithQueries = try #require(rowsWithQueries.first) #expect(row.duplicate?.id == existing.id) #expect(!row.isSelectedByDefault) #expect(row.resolutions == [.addCopy, .replace(existing.id)]) + #expect(rowWithQueries.resolutions == [.keepExisting(existing.id), .addCopy, .replace(existing.id)]) + } + + private func ordersConnection() -> DatabaseConnection { + DatabaseConnection( + name: "Orders", + type: .postgresql, + host: "db.example.com", + port: 5_432, + username: "app", + database: "orders" + ) + } + + private func keepingExisting(_ preview: ImportPreview, _ existing: DatabaseConnection) throws -> ImportPlan { + var selection = ImportSelection.defaults(for: preview) + selection.setSelected(true, connection: "c1", in: preview) + try #require(selection.resolution(for: "c1") == .keepExisting(existing.id)) + return ImportPlanner.plan(preview, selection: selection) } @Test("Two rows can never replace the same connection") diff --git a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift index 77e2e196c0..54d916c514 100644 --- a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift +++ b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift @@ -7,6 +7,14 @@ import Testing @MainActor struct MacOnlyImportRowTests { + private let fixture: AppStateFixture + private let appState: AppState + + init() throws { + fixture = try AppStateFixture() + appState = fixture.makeState(syncEnabled: false) + } + private func analyzed(type: String) throws -> ImportPreview { let settings = ExportableConnection( name: "Search", @@ -20,7 +28,7 @@ struct MacOnlyImportRowTests { return ConnectionImportAnalyzer.analyze( CollectedImport(bundle: bundle, source: .file(name: "Tests.tablepro")), library: ImportLibrarySnapshot(), - environment: IOSConnectionImportService.environment() + environment: IOSConnectionImportService.environment(for: appState) ) } diff --git a/apple/TableProMobile/TableProMobileTests/MobileImportOutcomeMessageTests.swift b/apple/TableProMobile/TableProMobileTests/MobileImportOutcomeMessageTests.swift new file mode 100644 index 0000000000..1f38a92f6f --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/MobileImportOutcomeMessageTests.swift @@ -0,0 +1,84 @@ +import Foundation +import TableProImport +@testable import TableProMobile +import Testing + +struct MobileImportOutcomeMessageTests { + @Test("Connections and saved queries are counted in one message") + func countsConnectionsAndQueries() { + let message = MobileImportOutcomeMessage(ImportOutcome(connectionsAdded: 1, savedQueriesAdded: 2)) + + #expect(message.title == String(localized: "Import Complete")) + #expect(message.message == "\(String(localized: "1 connection was imported.")) \(Self.queriesAdded(2))") + } + + @Test("Replaced connections count as imported") + func replacedConnectionsCount() { + let message = MobileImportOutcomeMessage(ImportOutcome(connectionsAdded: 2, connectionsReplaced: 1)) + + #expect(message.title == String(localized: "Import Complete")) + #expect(message.message == String(format: String(localized: "%d connections were imported."), 3)) + } + + @Test("Queries added to a kept connection report without a connection sentence") + func queriesOnly() { + let message = MobileImportOutcomeMessage(ImportOutcome(savedQueriesAdded: 1, savedQueriesNotImported: 3)) + + #expect(message.title == String(localized: "Import Complete")) + #expect(message.message == "\(String(localized: "1 saved query was added.")) \(Self.queriesNotImported(3))") + } + + @Test("Nothing written reads as nothing imported, not as a failure") + func nothingImported() { + let message = MobileImportOutcomeMessage(ImportOutcome()) + + #expect(message.title == String(localized: "Nothing Imported")) + #expect(message.message == String(localized: "Everything selected was already in your library.")) + } + + @Test("Saved queries skipped with nothing else written are counted, not called already saved") + func nothingImportedCountsSkippedQueries() { + let message = MobileImportOutcomeMessage(ImportOutcome(savedQueriesNotImported: 2)) + + #expect(message.title == String(localized: "Nothing Imported")) + #expect(message.message == Self.queriesNotImported(2)) + } + + @Test("A query store failure after connections were saved is incomplete") + func savedQueriesFailedAfterConnections() { + let message = MobileImportOutcomeMessage(ImportOutcome(connectionsAdded: 1, failure: .savedQueriesNotSaved)) + + #expect(message.title == String(localized: "Import Incomplete")) + #expect(message.message == "\(String(localized: "1 connection was imported.")) \(String(localized: "The saved queries could not be saved."))") + } + + @Test("A query store failure with no connection saved is a failure") + func savedQueriesFailedAlone() { + let message = MobileImportOutcomeMessage(ImportOutcome(failure: .savedQueriesNotSaved)) + + #expect(message.title == String(localized: "Import Failed")) + #expect(message.message == String(localized: "The saved queries could not be saved.")) + } + + @Test( + "A library or connection failure reports the failure only", + arguments: [ImportFailure.libraryUnreadable, ImportFailure.connectionsNotSaved] + ) + func storeFailures(failure: ImportFailure) { + let message = MobileImportOutcomeMessage(ImportOutcome(failure: failure)) + + #expect(message.title == String(localized: "Import Failed")) + let expected = failure == .libraryUnreadable + ? String(localized: "TablePro could not read your connection library, so nothing was imported.") + : String(localized: "The connections could not be saved.") + #expect(message.message == expected) + } + + private static func queriesAdded(_ count: Int) -> String { + String(format: String(localized: "%d saved queries were added."), count) + } + + private static func queriesNotImported(_ count: Int) -> String { + String(format: String(localized: "%d saved queries were not imported."), count) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Mocks/RecordingSyncTransport.swift b/apple/TableProMobile/TableProMobileTests/Mocks/RecordingSyncTransport.swift new file mode 100644 index 0000000000..413b50afe8 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Mocks/RecordingSyncTransport.swift @@ -0,0 +1,84 @@ +import CloudKit +import Foundation +@testable import TableProMobile +import TableProSyncTransport + +// Answers every pull with the records it holds and saves every push, recording both. +actor RecordingSyncTransport: IOSSyncTransport { + let currentZoneID = CKRecordZone.ID(zoneName: "RecordingZone", ownerName: CKCurrentUserDefaultName) + private var remoteRecords: [CKRecord] + private var remoteDeletions: [CKRecord.ID] + private var newToken: CKServerChangeToken? + private(set) var pushedRecords: [CKRecord] = [] + private(set) var pushedDeletions: [CKRecord.ID] = [] + private(set) var pullCount = 0 + + init( + remoteRecords: [CKRecord] = [], + remoteDeletions: [CKRecord.ID] = [], + newToken: CKServerChangeToken? = nil + ) { + self.remoteRecords = remoteRecords + self.remoteDeletions = remoteDeletions + self.newToken = newToken + } + + func answerPulls(with records: [CKRecord], deletions: [CKRecord.ID] = []) { + remoteRecords = records + remoteDeletions = deletions + } + + func accountStatus() async throws -> CKAccountStatus { + .available + } + + func currentAccountId() async throws -> String { + "recording-account" + } + + func ensureZoneExists() async throws {} + + func pull(since token: CKServerChangeToken?) async throws -> PullResult { + pullCount += 1 + return PullResult(changedRecords: remoteRecords, deletedRecordIDs: remoteDeletions, newToken: newToken) + } + + func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome { + pushedRecords.append(contentsOf: records) + pushedDeletions.append(contentsOf: deletions) + return PushOutcome( + savedRecords: Dictionary(records.map { ($0.recordID, $0) }) { _, last in last }, + deletedRecordIDs: Set(deletions) + ) + } + + // CKServerChangeToken has no public initializer, so a test token is decoded from an archive that names its class. + static func makeChangeToken() throws -> CKServerChangeToken { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + archiver.setClassName("CKServerChangeToken", for: RecordingChangeTokenArchive.self) + archiver.encode(RecordingChangeTokenArchive(), forKey: NSKeyedArchiveRootObjectKey) + archiver.finishEncoding() + guard let token = try NSKeyedUnarchiver.unarchivedObject( + ofClass: CKServerChangeToken.self, + from: archiver.encodedData + ) else { + throw CKError(.internalError) + } + return token + } +} + +@objc(RecordingSyncTransportChangeTokenArchive) +private final class RecordingChangeTokenArchive: NSObject, NSSecureCoding { + static var supportsSecureCoding: Bool { true } + + override init() { + super.init() + } + + required init?(coder: NSCoder) { + super.init() + } + + func encode(with coder: NSCoder) {} +} diff --git a/apple/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift b/apple/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift index ecc4a7db8e..85d24b749d 100644 --- a/apple/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift +++ b/apple/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift @@ -50,12 +50,13 @@ struct AppStateFixture { syncEnabled: Bool, secureStore: any SecureStore = MockSecureStore(), libraryPublisher: ConnectionLibraryPublisher? = nil, + transport: any IOSSyncTransport = UnreachableSyncTransport(), driverFactory: (any DriverFactory)? = nil ) -> AppState { let coordinator = IOSSyncCoordinator( metadata: metadata, recordCache: SyncRecordCache(directory: root.appendingPathComponent("Cache"), defaults: nil), - makeTransport: { UnreachableSyncTransport() }, + makeTransport: { transport }, isEnabled: { syncEnabled }, notificationCenter: NotificationCenter() ) diff --git a/apple/TableProMobile/TableProMobileTests/SavedQueries/SavedQueryDraftTests.swift b/apple/TableProMobile/TableProMobileTests/SavedQueries/SavedQueryDraftTests.swift new file mode 100644 index 0000000000..2f0da0f241 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SavedQueries/SavedQueryDraftTests.swift @@ -0,0 +1,285 @@ +import Foundation +import TableProImport +@testable import TableProMobile +import TableProModels +import Testing + +struct SavedQueryDraftTests { + private let connectionId = UUID() + + @Test("Saving the editor's text trims it, and whitespace alone saves nothing") + func savableSQLTrims() { + #expect(SavedQueryDraft.savableSQL(from: "\n SELECT 1; \n") == "SELECT 1;") + #expect(SavedQueryDraft.savableSQL(from: " \n\t ") == nil) + } + + @Test("A new draft takes its name from the first line, unfiled, not global, with no keyword") + func newDraftDefaults() { + #expect(SavedQueryDraft(saving: "-- Monthly totals\nSELECT 1") == SavedQueryDraft(name: "Monthly totals")) + } + + @Test("The keyword check runs in the Mac's order: spaces, then in use, then a shadowed SQL keyword") + func keywordCheckOrder() { + #expect(SavedQueryDraft(name: "q", keyword: " ").keywordCheck(isAvailable: false) == .valid) + #expect(SavedQueryDraft(name: "q", keyword: "two words").keywordCheck(isAvailable: false) == .containsSpaces) + #expect(SavedQueryDraft(name: "q", keyword: "sel").keywordCheck(isAvailable: false) == .inUse) + #expect(SavedQueryDraft(name: "q", keyword: " Select ").keywordCheck(isAvailable: true) == .shadowsSQLKeyword("Select")) + #expect(SavedQueryDraft(name: "q", keyword: "sel").keywordCheck(isAvailable: true) == .valid) + } + + @Test("A keyword warning still saves; a keyword error, a size over the limit, a blank name or blank SQL does not") + func canSaveRules() { + let draft = SavedQueryDraft(name: "Totals") + let fits = { (check: SavedQueryKeywordCheck) in SavedQueryValidation(keywordCheck: check, exceedsSizeLimit: false) } + #expect(draft.canSave(sql: "SELECT 1", validation: fits(.shadowsSQLKeyword("select")))) + #expect(!draft.canSave(sql: "SELECT 1", validation: fits(.inUse))) + #expect(!draft.canSave(sql: "SELECT 1", validation: fits(.containsSpaces))) + #expect(!draft.canSave(sql: "SELECT 1", validation: SavedQueryValidation(keywordCheck: .valid, exceedsSizeLimit: true))) + #expect(!SavedQueryDraft(name: " ").canSave(sql: "SELECT 1", validation: fits(.valid))) + #expect(!draft.canSave(sql: " \n", validation: fits(.valid))) + } + + @Test("Name, SQL and keyword over 900 KB together do not save") + func sizeLimit() { + let limit = SavedQuerySize.maximumSyncableByteCount + let draft = SavedQueryDraft(name: "ab", keyword: "k") + let exceeds = { (length: Int) in + draft.validation( + sql: String(repeating: "x", count: length), + savingOver: nil, + changedSince: draft, + connectionId: connectionId, + folders: [] + ) { _, _ in true }.exceedsSizeLimit + } + #expect(!exceeds(limit - 3)) + #expect(exceeds(limit - 2)) + } + + @Test("A new query belongs to the open connection unless Global, with a trimmed name and keyword") + func newQueryScope() { + let now = Date(timeIntervalSince1970: 1_000) + let folderId = UUID() + let scoped = SavedQueryDraft(name: " Totals ", keyword: " tot ", folderId: folderId) + .newQuery(sql: "SELECT 1", connectionId: connectionId, now: now) + #expect(scoped.name == "Totals") + #expect(scoped.query == "SELECT 1") + #expect(scoped.keyword == "tot") + #expect(scoped.folderId == folderId) + #expect(scoped.connectionId == connectionId) + #expect(scoped.sortOrder == 0) + #expect(scoped.createdAt == now) + #expect(scoped.updatedAt == now) + + let global = SavedQueryDraft(name: "Totals", isGlobal: true) + .newQuery(sql: "SELECT 1", connectionId: connectionId, now: now) + #expect(global.connectionId == nil) + #expect(global.keyword == nil) + } + + @Test("A rename writes only the name, keeping a folder and keyword a pull set after the form opened") + func renameKeepsPulledChanges() { + let opened = SavedQuery(name: "Totals", query: "SELECT 1", keyword: "tot", connectionId: connectionId) + let pulledFolder = UUID() + var live = opened + live.folderId = pulledFolder + live.keyword = "sum" + let opening = SavedQueryDraft(query: opened) + var edited = opening + edited.name = "Monthly totals " + + edited.apply(to: &live, changedSince: opening, connectionId: connectionId, folders: []) + + #expect(live.name == "Monthly totals") + #expect(live.folderId == pulledFolder) + #expect(live.keyword == "sum") + #expect(live.connectionId == connectionId) + #expect(live.createdAt == opened.createdAt) + } + + @Test("An untouched form changes nothing, and clearing the keyword removes it") + func keywordClearAndNoOp() { + let opened = SavedQuery(name: "Totals", query: "SELECT 1", keyword: "tot", connectionId: connectionId) + let opening = SavedQueryDraft(query: opened) + + var untouched = opened + opening.apply(to: &untouched, changedSince: opening, connectionId: connectionId, folders: []) + #expect(untouched == opened) + + var cleared = opened + var edited = opening + edited.keyword = " " + edited.apply(to: &cleared, changedSince: opening, connectionId: connectionId, folders: []) + #expect(cleared.keyword == nil) + } + + @Test("Turning Global on drops a folder that belongs to one connection and keeps a global one") + func globalDropsScopedFolder() { + let scopedFolder = SavedQueryFolder(name: "Mine", connectionId: connectionId) + let globalFolder = SavedQueryFolder(name: "Shared") + let folders = [scopedFolder, globalFolder] + + let inScoped = SavedQueryDraft(name: "Totals", folderId: scopedFolder.id, isGlobal: true) + #expect(inScoped.resolvingFolder(among: folders, connectionId: connectionId).folderId == nil) + + let inShared = SavedQueryDraft(name: "Totals", folderId: globalFolder.id, isGlobal: true) + #expect(inShared.resolvingFolder(among: folders, connectionId: connectionId).folderId == globalFolder.id) + + let scoped = SavedQueryDraft(name: "Totals", folderId: scopedFolder.id) + #expect(scoped.resolvingFolder(among: folders, connectionId: connectionId).folderId == scopedFolder.id) + } + + @Test("A keyword is checked in the scope the row is saved with, even one a pull gave it while the form was open") + func validationUsesTheSavedScope() { + let opened = SavedQuery(name: "Totals", query: "SELECT 1", connectionId: connectionId) + var madeGlobal = opened + madeGlobal.connectionId = nil + let opening = SavedQueryDraft(query: opened) + var edited = opening + edited.keyword = "tot" + var checkedScopes: [UUID?] = [] + let heldInAnotherConnection = { (_: String, scope: UUID?) -> Bool in + checkedScopes.append(scope) + return scope != nil + } + + let pulled = edited.validation( + sql: opened.query, + savingOver: madeGlobal, + changedSince: opening, + connectionId: connectionId, + folders: [], + isAvailable: heldInAnotherConnection + ) + let deleted = edited.validation( + sql: opened.query, + savingOver: nil, + changedSince: opening, + connectionId: connectionId, + folders: [], + isAvailable: heldInAnotherConnection + ) + + #expect(pulled.keywordCheck == .inUse) + #expect(deleted.keywordCheck == .valid) + #expect(checkedScopes == [nil, connectionId]) + } + + @Test("A keyword or SQL a pull gave the row while the form was open is checked with the form's edits") + func validationUsesThePulledRow() { + let limit = SavedQuerySize.maximumSyncableByteCount + let opened = SavedQuery(name: "Totals", query: "SELECT 1", connectionId: connectionId) + var pulled = opened + pulled.keyword = "tot" + pulled.query = String(repeating: "x", count: limit - 10) + let opening = SavedQueryDraft(query: opened) + var edited = opening + edited.isGlobal = true + edited.name = "A longer name than before" + + let validation = edited.validation( + sql: opened.query, + savingOver: pulled, + changedSince: opening, + connectionId: connectionId, + folders: [] + ) { _, scope in scope != nil } + + #expect(validation.keywordCheck == .inUse) + #expect(validation.exceedsSizeLimit) + #expect(!edited.canSave(sql: opened.query, validation: validation)) + } + + @Test("Saving a query as Global drops the stored row's scoped folder, even one a pull put it in") + func applyGlobalChecksStoredFolder() { + let scopedFolder = SavedQueryFolder(name: "Mine", connectionId: connectionId) + let opened = SavedQuery(name: "Totals", query: "SELECT 1", connectionId: connectionId) + var live = opened + live.folderId = scopedFolder.id + let opening = SavedQueryDraft(query: opened) + var edited = opening + edited.isGlobal = true + + edited.apply(to: &live, changedSince: opening, connectionId: connectionId, folders: [scopedFolder]) + + #expect(live.connectionId == nil) + #expect(live.folderId == nil) + } + + @Test("A rename leaves a global query in a connection's folder where the Mac put it") + func renameKeepsEarlierPlacement() { + let scopedFolder = SavedQueryFolder(name: "Mine", connectionId: connectionId) + let opened = SavedQuery(name: "Totals", query: "SELECT 1", folderId: scopedFolder.id) + var live = opened + let opening = SavedQueryDraft(query: opened) + var edited = opening + edited.name = "Monthly" + + edited.apply(to: &live, changedSince: opening, connectionId: connectionId, folders: [scopedFolder]) + + #expect(live.name == "Monthly") + #expect(live.folderId == scopedFolder.id) + } + + @Test("A move refuses a folder narrower than the query, and None files it at the top level") + func moveRules() { + let scopedFolder = SavedQueryFolder(name: "Mine", connectionId: connectionId) + let globalFolder = SavedQueryFolder(name: "Shared") + var global = SavedQuery(name: "Totals", query: "SELECT 1") + + SavedQueryDraft.move(&global, to: scopedFolder) + #expect(global.folderId == nil) + + SavedQueryDraft.move(&global, to: globalFolder) + #expect(global.folderId == globalFolder.id) + + SavedQueryDraft.move(&global, to: nil) + #expect(global.folderId == nil) + + var scoped = SavedQuery(name: "Totals", query: "SELECT 1", connectionId: connectionId) + SavedQueryDraft.move(&scoped, to: scopedFolder) + #expect(scoped.folderId == scopedFolder.id) + } + + @Test("A rename takes the trimmed name; a blank, unchanged or oversized one does nothing") + func renamedName() { + let query = SavedQuery(name: "Totals", query: "SELECT 1") + #expect(SavedQueryDraft.renamedName(" Monthly ", of: query) == "Monthly") + #expect(SavedQueryDraft.renamedName(" ", of: query) == nil) + #expect(SavedQueryDraft.renamedName("Totals ", of: query) == nil) + let oversized = String(repeating: "n", count: SavedQuerySize.maximumSyncableByteCount) + #expect(SavedQueryDraft.renamedName(oversized, of: query) == nil) + } + + @Test("A folder name is trimmed, and must differ from the current one") + func acceptedFolderName() { + #expect(SavedQueryDraft.acceptedName(" Reports ", replacing: nil) == "Reports") + #expect(SavedQueryDraft.acceptedName(" ", replacing: nil) == nil) + #expect(SavedQueryDraft.acceptedName("Reports", replacing: "Reports") == nil) + } + + @Test("A failed write says deleted when the record is gone, and not saved when it is still there") + func writeFailureKind() { + let query = SavedQuery(name: "Totals", query: "SELECT 1") + #expect(SavedQueryWriteFailure.failedQueryWrite(query.id, in: []) == .queryDeleted) + #expect(SavedQueryWriteFailure.failedQueryWrite(query.id, in: [query]) == .notSaved) + + let folder = SavedQueryFolder(name: "Shared") + #expect(SavedQueryWriteFailure.failedFolderWrite(folder.id, in: []) == .folderDeleted) + #expect(SavedQueryWriteFailure.failedFolderWrite(folder.id, in: [folder]) == .notSaved) + } + + @Test("The row preview is the first line that is not blank or a comment, capped at 50 characters") + func previewFirstLine() { + #expect(SavedQueryPreview.firstLine(of: "\n -- Totals\n\n SELECT id FROM t \nWHERE 1") == "SELECT id FROM t") + #expect(SavedQueryPreview.firstLine(of: "-- only a comment\n ") == nil) + #expect(SavedQueryPreview.firstLine(of: "") == nil) + #expect(SavedQueryPreview.firstLine(of: String(repeating: "x", count: 900_000)) == String(repeating: "x", count: 50)) + } + + @Test("The preview cap never splits a character") + func previewKeepsCharactersWhole() { + let preview = SavedQueryPreview.firstLine(of: String(repeating: "a", count: 49) + "\u{1F600} tail") + #expect(preview == String(repeating: "a", count: 49)) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/SavedQueries/SavedQueryLibraryTests.swift b/apple/TableProMobile/TableProMobileTests/SavedQueries/SavedQueryLibraryTests.swift new file mode 100644 index 0000000000..fa4e64e86c --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SavedQueries/SavedQueryLibraryTests.swift @@ -0,0 +1,262 @@ +import Foundation +import TableProImport +@testable import TableProMobile +import TableProModels +import TableProSyncTransport +import Testing + +@MainActor +struct SavedQueryLibraryTests { + private let directory: URL + private let metadata: SyncMetadataStorage + private let coordinator: IOSSyncCoordinator + + init() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("saved-queries-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.SavedQueries.\(UUID().uuidString)")) + metadata = SyncMetadataStorage(userDefaults: defaults) + coordinator = IOSSyncCoordinator( + metadata: metadata, + recordCache: SyncRecordCache(directory: directory.appendingPathComponent("Cache"), defaults: nil), + makeTransport: { UnreachableSyncTransport() }, + isEnabled: { false }, + notificationCenter: NotificationCenter(), + networkMonitor: nil + ) + } + + private var file: URL { + directory.appendingPathComponent("saved-queries.json") + } + + private func makeLibrary() -> SavedQueryLibrary { + SavedQueryLibrary(directory: directory, syncCoordinator: coordinator) + } + + private func setWritable(_ writable: Bool) throws { + try FileManager.default.setAttributes([.posixPermissions: writable ? 0o755 : 0o555], ofItemAtPath: directory.path) + } + + @Test("Updating a query deleted meanwhile reports it and writes nothing") + func updateOfDeletedQueryFails() throws { + let library = makeLibrary() + let query = SavedQuery(name: "One", query: "SELECT 1") + #expect(library.insert(query)) + #expect(library.applySynced(SavedQueryContents())) + let before = try Data(contentsOf: file) + + let updated = library.update(query.id) { $0.name = "Renamed" } + + #expect(!updated) + #expect(try Data(contentsOf: file) == before) + #expect(library.contents.queries.isEmpty) + } + + @Test("A rename keeps the folder a pull moved the query into while the form was open") + func renameKeepsPulledFolder() throws { + let library = makeLibrary() + let folder = SavedQueryFolder(name: "Reports") + let query = SavedQuery(name: "Old", query: "SELECT 1") + #expect(library.insert(query)) + var pulled = library.contents + pulled.folders = [folder] + pulled.queries[0].folderId = folder.id + #expect(library.applySynced(pulled)) + + let renamed = library.update(query.id) { $0.name = "New" } + + #expect(renamed) + let stored = try #require(library.contents.query(query.id)) + #expect(stored.name == "New") + #expect(stored.folderId == folder.id) + #expect(stored.createdAt == query.createdAt) + #expect(metadata.dirtyIds(for: .favorite).contains(query.id.uuidString)) + } + + @Test("A write that fails leaves the stored queries and the sync marks as they were") + func failedWriteKeepsContents() throws { + let library = makeLibrary() + let kept = SavedQuery(name: "Kept", query: "SELECT 1") + #expect(library.insert(kept)) + try setWritable(false) + defer { try? setWritable(true) } + let added = SavedQuery(name: "Added", query: "SELECT 2") + + #expect(!library.insert(added)) + #expect(!library.deleteQuery(kept.id)) + #expect(!library.applySynced(SavedQueryContents())) + + let ids = library.contents.queries.map(\.id) + #expect(ids == [kept.id]) + #expect(!metadata.dirtyIds(for: .favorite).contains(added.id.uuidString)) + #expect(metadata.tombstones(for: .favorite).isEmpty) + } + + @Test("A file that fails to load is kept, refuses writes and sync, and loads on a retry") + func failedLoadRetries() throws { + let unreadable = Data("{ not json".utf8) + try unreadable.write(to: file) + let library = makeLibrary() + + #expect(library.status == .failed) + #expect(library.syncContents == nil) + #expect(library.ledgerEntries() == nil) + #expect(!library.insert(SavedQuery(name: "One", query: "SELECT 1"))) + #expect(try Data(contentsOf: file) == unreadable) + + let repaired = SavedQuery(name: "Repaired", query: "SELECT 1") + try JSONEncoder().encode(SavedQueryContents(queries: [repaired])).write(to: file) + library.retryLoadIfFailed() + + #expect(library.status == .ready) + let ids = library.contents.queries.map(\.id) + #expect(ids == [repaired.id]) + } + + @Test("A connection deleted while the file is unreadable takes its saved queries once the file loads, after a relaunch too") + func deferredPurgeRunsOnceLoaded() throws { + let deletedId = UUID() + let scoped = SavedQuery(name: "Scoped", query: "SELECT 1", connectionId: deletedId) + let global = SavedQuery(name: "Global", query: "SELECT 2") + try Data("{ not json".utf8).write(to: file) + let library = makeLibrary() + + library.removeRecords(ofConnections: [deletedId], tombstoning: true) + try JSONEncoder().encode(SavedQueryContents(queries: [scoped, global])).write(to: file) + let relaunched = makeLibrary() + + let ids = relaunched.contents.queries.map(\.id) + let tombstoned = metadata.tombstones(for: .favorite).map(\.id) + #expect(ids == [global.id]) + #expect(tombstoned == [scoped.id.uuidString]) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("saved-query-purges.json").path)) + } + + @Test("A keyword is taken by its own connection and by global queries, never by the query being edited") + func keywordAvailability() { + let library = makeLibrary() + let connectionId = UUID() + let otherId = UUID() + let scoped = SavedQuery(name: "Scoped", query: "SELECT 1", keyword: "one", connectionId: connectionId) + let global = SavedQuery(name: "Global", query: "SELECT 2", keyword: "all") + #expect(library.insert(scoped)) + #expect(library.insert(global)) + + #expect(!library.isKeywordAvailable("one", connectionId: connectionId)) + #expect(library.isKeywordAvailable("one", connectionId: otherId)) + #expect(!library.isKeywordAvailable("one", connectionId: nil)) + #expect(!library.isKeywordAvailable("all", connectionId: otherId)) + #expect(library.isKeywordAvailable(" one ", connectionId: connectionId, excluding: scoped.id)) + #expect(library.isKeywordAvailable(" ", connectionId: nil)) + } + + @Test("Deleting a folder moves its contents to its parent, deletes it in iCloud and pushes what moved") + func folderDeleteReparents() throws { + let library = makeLibrary() + let parent = SavedQueryFolder(name: "Parent") + let folder = SavedQueryFolder(name: "Folder", parentId: parent.id) + let child = SavedQueryFolder(name: "Child", parentId: folder.id) + let query = SavedQuery(name: "Inside", query: "SELECT 1", folderId: folder.id) + #expect(library.insertFolder(parent)) + #expect(library.insertFolder(folder)) + #expect(library.insertFolder(child)) + #expect(library.insert(query)) + metadata.clearDirty(type: .favorite) + metadata.clearDirty(type: .favoriteFolder) + + #expect(library.deleteFolder(folder.id)) + + #expect(library.contents.folder(folder.id) == nil) + #expect(library.contents.folder(child.id)?.parentId == parent.id) + #expect(library.contents.query(query.id)?.folderId == parent.id) + let tombstoned = metadata.tombstones(for: .favoriteFolder).map(\.id) + #expect(tombstoned == [folder.id.uuidString]) + #expect(metadata.dirtyIds(for: .favorite) == [query.id.uuidString]) + #expect(metadata.dirtyIds(for: .favoriteFolder) == [child.id.uuidString]) + } + + @Test("A folder cannot be moved inside itself") + func folderCycleRefused() { + let library = makeLibrary() + let outer = SavedQueryFolder(name: "Outer") + let inner = SavedQueryFolder(name: "Inner", parentId: outer.id) + #expect(library.insertFolder(outer)) + #expect(library.insertFolder(inner)) + + let moved = library.updateFolder(outer.id) { $0.parentId = inner.id } + + #expect(!moved) + #expect(library.contents.folder(outer.id)?.parentId == nil) + } + + @Test("A query owned by the sample never syncs, and one moved there is held in iCloud as it was until it is made global") + func sampleScopeStaysLocal() { + let library = makeLibrary() + let sampleId = UUID() + library.syncsOwner = { $0 != sampleId } + let local = SavedQuery(name: "Sample", query: "SELECT 1", connectionId: sampleId) + let query = SavedQuery(name: "Global", query: "SELECT 2") + #expect(library.insert(local)) + #expect(library.insert(query)) + #expect(metadata.dirtyIds(for: .favorite) == [query.id.uuidString]) + metadata.clearDirty(type: .favorite) + + let movedToSample = library.update(query.id) { $0.connectionId = sampleId } + #expect(movedToSample) + #expect(metadata.dirtyIds(for: .favorite).isEmpty) + #expect(metadata.tombstones(for: .favorite).isEmpty) + + let madeGlobal = library.update(query.id) { $0.connectionId = nil } + #expect(madeGlobal) + #expect(metadata.tombstones(for: .favorite).isEmpty) + #expect(metadata.dirtyIds(for: .favorite) == [query.id.uuidString]) + } + + @Test("A purge whose write failed runs with the next write that lands") + func deferredPurgeRunsOnTheNextWrite() throws { + let library = makeLibrary() + let deletedId = UUID() + let scoped = SavedQuery(name: "Scoped", query: "SELECT 1", connectionId: deletedId) + #expect(library.insert(scoped)) + try setWritable(false) + library.removeRecords(ofConnections: [deletedId], tombstoning: true) + #expect(library.contents.query(scoped.id) != nil) + try setWritable(true) + + #expect(library.insert(SavedQuery(name: "Later", query: "SELECT 2"))) + + #expect(library.contents.query(scoped.id) == nil) + let tombstoned = metadata.tombstones(for: .favorite).map(\.id) + #expect(tombstoned == [scoped.id.uuidString]) + } + + @Test("An import is decided against the stored queries, creates its folder once and marks what it added") + func importDecidesAgainstStoredRows() throws { + let library = makeLibrary() + let connectionId = UUID() + #expect(library.insert(SavedQuery(name: "Saved", query: "SELECT 1", connectionId: connectionId))) + metadata.clearDirty(type: .favorite) + let path = [PathComponent(name: "Reports", scope: connectionId, color: nil)] + let planned = [ + PlannedQuery(ref: "q1", name: "Saved", sql: "SELECT 1", keyword: nil, connectionId: connectionId, folderPath: path), + PlannedQuery(ref: "q2", name: " ", sql: "SELECT 2\nFROM t", keyword: "two", connectionId: connectionId, folderPath: path), + PlannedQuery(ref: "q3", name: "Other", sql: "SELECT 3", keyword: nil, connectionId: connectionId, folderPath: path) + ] + + let write = try #require(library.importSavedQueries(planned)) + + #expect(write.alreadySaved == 1) + #expect(write.insertedIds.count == 2) + #expect(write.createdFolderIds.count == 1) + let folderId = try #require(write.createdFolderIds.first) + let added = library.contents.queries.filter { write.insertedIds.contains($0.id) } + #expect(added.allSatisfy { $0.folderId == folderId }) + #expect(added.first?.name == "SELECT 2") + #expect(added.first?.keyword == "two") + #expect(metadata.dirtyIds(for: .favorite) == Set(write.insertedIds.map(\.uuidString))) + #expect(metadata.dirtyIds(for: .favoriteFolder) == [folderId.uuidString]) + #expect(library.ledgerEntries()?.count == 3) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Sync/AppStateSyncWiringTests.swift b/apple/TableProMobile/TableProMobileTests/Sync/AppStateSyncWiringTests.swift new file mode 100644 index 0000000000..345bdfc222 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Sync/AppStateSyncWiringTests.swift @@ -0,0 +1,85 @@ +import CloudKit +import Foundation +@testable import TableProMobile +import TableProModels +import TableProSync +import TableProSyncTransport +import Testing + +@MainActor +struct AppStateSyncWiringTests { + private let fixture: AppStateFixture + private let zoneID = CKRecordZone.ID(zoneName: "RecordingZone", ownerName: CKCurrentUserDefaultName) + + init() throws { + fixture = try AppStateFixture() + } + + private func makeSyncedState(transport: RecordingSyncTransport) -> AppState { + let state = fixture.makeState(syncEnabled: true, transport: transport) + state.connectSync() + return state + } + + @Test("A saved query made on the Mac reaches the saved queries and the file through the app's sync wiring") + func pulledSavedQueryReachesTheLibrary() async { + let folder = SavedQueryFolder(name: "Reports") + let query = SavedQuery(name: "One", query: "SELECT 1", keyword: "one", folderId: folder.id) + let transport = RecordingSyncTransport(remoteRecords: [ + SyncRecordMapper.toRecord(folder, zoneID: zoneID), + SyncRecordMapper.toRecord(query, zoneID: zoneID) + ]) + let state = makeSyncedState(transport: transport) + + await state.syncCoordinator.sync() + + #expect(state.savedQueries.contents == SavedQueryContents(queries: [query], folders: [folder])) + let reloaded = fixture.makeState(syncEnabled: false).savedQueries.contents + let reloadedIds = reloaded.queries.map(\.id) + #expect(reloadedIds == [query.id]) + #expect(reloaded.queries.first?.folderId == folder.id) + } + + @Test("A saved query made on iPhone is pushed through the app's sync wiring, and one the sample owns is not") + func savedQueryIsPushed() async throws { + let transport = RecordingSyncTransport() + let state = makeSyncedState(transport: transport) + let sampleId = try state.openSampleDatabase() + let global = SavedQuery(name: "Global", query: "SELECT 1") + let sampleQuery = SavedQuery(name: "Sample", query: "SELECT 2", connectionId: sampleId) + #expect(state.savedQueries.insert(global)) + #expect(state.savedQueries.insert(sampleQuery)) + + await state.syncCoordinator.sync() + + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toSavedQuery).map(\.id) + #expect(pushed == [global.id]) + #expect(fixture.metadata.dirtyIds(for: .favorite).isEmpty) + } + + @Test("A query edited on iPhone while the Mac moved it keeps both changes") + func editKeepsThePulledMove() async throws { + let folder = SavedQueryFolder(name: "Reports") + let query = SavedQuery(name: "Old", query: "SELECT 1") + let transport = RecordingSyncTransport() + let state = makeSyncedState(transport: transport) + #expect(state.savedQueries.insert(query)) + await state.syncCoordinator.sync() + + var moved = query + moved.folderId = folder.id + await transport.answerPulls(with: [ + SyncRecordMapper.toRecord(folder, zoneID: zoneID), + SyncRecordMapper.toRecord(moved, zoneID: zoneID) + ]) + await state.syncCoordinator.sync() + let renamed = state.savedQueries.update(query.id) { $0.name = "New" } + #expect(renamed) + await state.syncCoordinator.sync() + + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toSavedQuery) + let last = try #require(pushed.last) + #expect(last.name == "New") + #expect(last.folderId == folder.id) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift b/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift index 634f6a417f..cb60933cae 100644 --- a/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift @@ -11,6 +11,7 @@ private final class LibraryStateBox { var connections: [DatabaseConnection] = [] var groups: [ConnectionGroup] = [] var tags: [ConnectionTag] = [] + var savedQueries: SavedQueryContents? = SavedQueryContents() var duringPull: () -> Void = {} var duringPush: () -> Void = {} var duringAccountCheck: () -> Void = {} @@ -32,6 +33,7 @@ private final class LibraryStateBox { private actor FakeSyncTransport: IOSSyncTransport { let currentZoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) private let remoteRecords: [CKRecord] + private let remoteDeletions: [CKRecord.ID] private let box: LibraryStateBox private var accountId: String? private let recordsTheServerNeverHad: Set @@ -51,9 +53,11 @@ private actor FakeSyncTransport: IOSSyncTransport { remoteRecords: [CKRecord], box: LibraryStateBox, accountId: String? = "account-a", - recordsTheServerNeverHad: Set = [] + recordsTheServerNeverHad: Set = [], + remoteDeletions: [CKRecord.ID] = [] ) { self.remoteRecords = remoteRecords + self.remoteDeletions = remoteDeletions self.box = box self.accountId = accountId self.recordsTheServerNeverHad = recordsTheServerNeverHad @@ -123,7 +127,7 @@ private actor FakeSyncTransport: IOSSyncTransport { if !pullErrors.isEmpty { throw pullErrors.removeFirst() } - return PullResult(changedRecords: remoteRecords, deletedRecordIDs: [], newToken: newToken) + return PullResult(changedRecords: remoteRecords, deletedRecordIDs: remoteDeletions, newToken: newToken) } func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome { @@ -165,6 +169,8 @@ struct IOSSyncCoordinatorTests { init() throws { let defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.IOSSync.\(UUID().uuidString)")) metadata = SyncMetadataStorage(userDefaults: defaults) + // As on a device that already ran this build, so a run keeps the change token it found. + _ = metadata.adoptReadableRecordTypes(IOSSyncCoordinator.readableRecordTypes) cacheDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("ios-sync-cache-\(UUID().uuidString)", isDirectory: true) } @@ -185,9 +191,11 @@ struct IOSSyncCoordinatorTests { networkMonitor: nil ) coordinator.getCurrentState = { (box.connections, box.groups, box.tags) } - coordinator.onConnectionsChanged = { box.connections = $0 } - coordinator.onGroupsChanged = { box.groups = $0 } - coordinator.onTagsChanged = { box.tags = $0 } + coordinator.onConnectionsChanged = { box.connections = $0; return true } + coordinator.onGroupsChanged = { box.groups = $0; return true } + coordinator.onTagsChanged = { box.tags = $0; return true } + coordinator.getSavedQueries = { box.savedQueries } + coordinator.onSavedQueriesChanged = { box.savedQueries = $0; return true } return coordinator } @@ -981,6 +989,198 @@ struct IOSSyncCoordinatorTests { #expect(coordinator.status == .idle) } + // MARK: - Saved queries + + @Test("A saved query and its folder saved on the Mac reach the store") + func pulledSavedQueryIsStored() async { + let box = LibraryStateBox() + let connectionId = UUID() + let folder = SavedQueryFolder(name: "Reports", connectionId: connectionId) + let query = SavedQuery(name: "One", query: "SELECT 1", keyword: "one", folderId: folder.id, connectionId: connectionId) + let records = [SyncRecordMapper.toRecord(folder, zoneID: zoneID), SyncRecordMapper.toRecord(query, zoneID: zoneID)] + let transport = FakeSyncTransport(remoteRecords: records, box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + + await coordinator.sync() + + #expect(box.savedQueries == SavedQueryContents(queries: [query], folders: [folder])) + } + + @Test("A full upload sends every saved query except those the sample owns, even when one is marked") + func sampleSavedQueriesStayOnTheDevice() async { + let box = LibraryStateBox() + let sample = DatabaseConnection(name: "Sample", type: .sqlite, database: "Chinook.sqlite", isSample: true) + box.connections = [sample] + let global = SavedQuery(name: "Global", query: "SELECT 1") + let sampleQuery = SavedQuery(name: "Sample", query: "SELECT 2", connectionId: sample.id) + let sampleFolder = SavedQueryFolder(name: "Sample", connectionId: sample.id) + box.savedQueries = SavedQueryContents(queries: [global, sampleQuery], folders: [sampleFolder]) + metadata.zoneState = .removed + let transport = FakeSyncTransport(remoteRecords: [], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + + coordinator.setEnabled(true) + #expect(metadata.dirtyIds(for: .favorite) == [global.id.uuidString]) + #expect(metadata.dirtyIds(for: .favoriteFolder).isEmpty) + coordinator.markDirty(savedQueries: [sampleQuery.id], folders: [sampleFolder.id]) + await coordinator.sync() + + let pushed = await transport.pushedRecords.map(\.recordID.recordName) + #expect(pushed == [SyncRecordMapper.recordID(type: .favorite, id: global.id.uuidString, in: zoneID).recordName]) + } + + @Test("A saved query and a folder deleted on iPhone are deleted in iCloud") + func savedQueryDeletionsArePushed() async { + let box = LibraryStateBox() + let queryId = UUID() + let folderId = UUID() + let transport = FakeSyncTransport(remoteRecords: [], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDeleted(savedQueries: [queryId], folders: [folderId]) + + await coordinator.sync() + + let deleted = Set(await transport.pushedDeletions.map(\.recordName)) + #expect(deleted == [ + SyncRecordMapper.recordID(type: .favorite, id: queryId.uuidString, in: zoneID).recordName, + SyncRecordMapper.recordID(type: .favoriteFolder, id: folderId.uuidString, in: zoneID).recordName + ]) + #expect(metadata.tombstones(for: .favorite).isEmpty) + #expect(metadata.tombstones(for: .favoriteFolder).isEmpty) + } + + @Test("An older pulled query takes a contested keyword, and the younger one here gives it up in iCloud too") + func keywordContestReleasesTheYounger() async { + let box = LibraryStateBox() + let connectionId = UUID() + let local = SavedQuery(name: "Local", query: "SELECT 1", keyword: "one", connectionId: connectionId) + let remote = SavedQuery( + name: "Remote", + query: "SELECT 2", + keyword: "one", + connectionId: connectionId, + createdAt: local.createdAt.addingTimeInterval(-3_600) + ) + box.savedQueries = SavedQueryContents(queries: [local]) + let transport = FakeSyncTransport(remoteRecords: [SyncRecordMapper.toRecord(remote, zoneID: zoneID)], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + + await coordinator.sync() + + let stored = box.savedQueries ?? SavedQueryContents() + #expect(stored.queries.count == 2) + #expect(stored.query(remote.id)?.keyword == "one") + #expect(stored.query(local.id)?.keyword == nil) + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toSavedQuery) + #expect(pushed.map(\.id) == [local.id]) + #expect(pushed.first?.keyword == nil) + } + + @Test("A stale copy that still claims a keyword its query lost is corrected in iCloud, though nothing changes here") + func staleKeywordClaimIsPushed() async { + let box = LibraryStateBox() + let connectionId = UUID() + let holder = SavedQuery( + name: "Holder", + query: "SELECT 1", + keyword: "one", + connectionId: connectionId, + createdAt: Date(timeIntervalSinceReferenceDate: 100) + ) + let loser = SavedQuery( + name: "Loser", + query: "SELECT 2", + connectionId: connectionId, + createdAt: Date(timeIntervalSinceReferenceDate: 200), + updatedAt: Date(timeIntervalSinceReferenceDate: 200) + ) + var stale = loser + stale.keyword = "one" + box.savedQueries = SavedQueryContents(queries: [holder, loser]) + let transport = FakeSyncTransport(remoteRecords: [SyncRecordMapper.toRecord(stale, zoneID: zoneID)], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + + await coordinator.sync() + + #expect(box.savedQueries == SavedQueryContents(queries: [holder, loser])) + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toSavedQuery) + #expect(pushed.map(\.id) == [loser.id]) + #expect(pushed.first?.keyword == nil) + } + + @Test("A query moved to the sample stays on the device when its old iCloud copy is deleted elsewhere") + func sampleQueryOutlivesItsOldCopy() async { + let box = LibraryStateBox() + let sample = DatabaseConnection(name: "Sample", type: .sqlite, database: "Chinook.sqlite", isSample: true) + box.connections = [sample] + let query = SavedQuery(name: "Moved", query: "SELECT 1", connectionId: sample.id) + box.savedQueries = SavedQueryContents(queries: [query]) + let deletion = SyncRecordMapper.recordID(type: .favorite, id: query.id.uuidString, in: zoneID) + let transport = FakeSyncTransport(remoteRecords: [], box: box, remoteDeletions: [deletion]) + let coordinator = makeCoordinator(box: box, transport: transport) + + await coordinator.sync() + + #expect(box.savedQueries == SavedQueryContents(queries: [query])) + } + + @Test("A folder deleted on the Mac moves what was inside it up a level") + func remoteFolderDeletionMovesContentsUp() async { + let box = LibraryStateBox() + let parent = SavedQueryFolder(name: "Parent") + let deleted = SavedQueryFolder(name: "Deleted", parentId: parent.id) + let child = SavedQueryFolder(name: "Child", parentId: deleted.id) + let query = SavedQuery(name: "Inside", query: "SELECT 1", folderId: deleted.id) + box.savedQueries = SavedQueryContents(queries: [query], folders: [parent, deleted, child]) + let deletion = SyncRecordMapper.recordID(type: .favoriteFolder, id: deleted.id.uuidString, in: zoneID) + let transport = FakeSyncTransport(remoteRecords: [], box: box, remoteDeletions: [deletion]) + let coordinator = makeCoordinator(box: box, transport: transport) + + await coordinator.sync() + + let contents = box.savedQueries ?? SavedQueryContents() + #expect(contents.folders.map(\.id) == [parent.id, child.id]) + #expect(contents.folder(child.id)?.parentId == parent.id) + #expect(contents.query(query.id)?.folderId == parent.id) + #expect(await transport.pushedRecords.isEmpty) + } + + @Test("A build that reads saved queries fetches everything once on a device that synced without them") + func grownReadableSetFetchesEverythingOnce() async { + let box = LibraryStateBox() + metadata.lastAccountId = "account-a" + _ = metadata.adoptReadableRecordTypes(["Connection", "ConnectionGroup", "ConnectionTag"]) + metadata.userDefaults.set(Data([1, 2, 3]), forKey: tokenKey) + let transport = FakeSyncTransport(remoteRecords: [], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + var tokensDuringPull: [Data?] = [] + box.duringPull = { tokensDuringPull.append(metadata.userDefaults.data(forKey: tokenKey)) } + + await coordinator.sync() + metadata.userDefaults.set(Data([1, 2, 3]), forKey: tokenKey) + await coordinator.sync() + + #expect(tokensDuringPull == [nil, Data([1, 2, 3])]) + } + + @Test("A connection and a saved query deleted here are not brought back by a pull before their deletion goes up") + func pendingDeletionsAreNotBroughtBack() async { + let box = LibraryStateBox() + let connection = DatabaseConnection(name: "Deleted", type: .mysql) + let query = SavedQuery(name: "Deleted", query: "SELECT 1") + let records = [SyncRecordMapper.toRecord(connection, zoneID: zoneID), SyncRecordMapper.toRecord(query, zoneID: zoneID)] + let transport = FakeSyncTransport(remoteRecords: records, box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDeleted(connection.id) + coordinator.markDeleted(savedQueries: [query.id]) + + await coordinator.sync() + + #expect(box.connections.isEmpty) + #expect(box.savedQueries?.queries.isEmpty == true) + #expect(await transport.pushedDeletions.count == 2) + } + private static func makeChangeToken() throws -> CKServerChangeToken { let archiver = NSKeyedArchiver(requiringSecureCoding: true) archiver.setClassName("CKServerChangeToken", for: ChangeTokenArchive.self) diff --git a/apple/TableProMobile/TableProMobileTests/Sync/SavedQueryRemoteMergeTests.swift b/apple/TableProMobile/TableProMobileTests/Sync/SavedQueryRemoteMergeTests.swift new file mode 100644 index 0000000000..364fc16df5 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Sync/SavedQueryRemoteMergeTests.swift @@ -0,0 +1,154 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +struct SavedQueryRemoteMergeTests { + private let connectionId = UUID() + + private func merge( + _ local: SavedQueryContents, + _ remote: SavedQueryRemoteChanges, + pending: SavedQueryPendingEdits = SavedQueryPendingEdits(), + keptLocalOwners: Set = [] + ) -> SavedQueryMergeOutcome { + SavedQueryRemoteMerge.merge(local: local, remote: remote, pending: pending, keptLocalOwners: keptLocalOwners) + } + + @Test("A query edited here and not pushed yet keeps the local edit over the pulled copy") + func dirtyLocalQueryWins() { + let original = SavedQuery(name: "Old", query: "SELECT 1") + var local = original + local.name = "Edited here" + var remote = original + remote.name = "Edited on the Mac" + + let outcome = merge( + SavedQueryContents(queries: [local]), + SavedQueryRemoteChanges(changedQueries: [remote]), + pending: SavedQueryPendingEdits(dirtyQueryIds: [original.id.uuidString]) + ) + + #expect(outcome.contents.queries == [local]) + } + + @Test("A pulled query or folder whose deletion here has not gone up yet stays deleted") + func pendingDeletionsStayDeleted() { + let query = SavedQuery(name: "Deleted", query: "SELECT 1") + let folder = SavedQueryFolder(name: "Deleted") + + let outcome = merge( + SavedQueryContents(), + SavedQueryRemoteChanges(changedQueries: [query], changedFolders: [folder]), + pending: SavedQueryPendingEdits( + deletedQueryIds: [query.id.uuidString], + deletedFolderIds: [folder.id.uuidString] + ) + ) + + #expect(outcome.contents.isEmpty) + } + + @Test("A pulled change replaces a clean copy and keeps the creation date stored here") + func upsertKeepsStoredCreationDate() throws { + let stored = SavedQuery(name: "Old", query: "SELECT 1", createdAt: Date(timeIntervalSinceReferenceDate: 100)) + let pulled = SavedQuery( + id: stored.id, + name: "New", + query: "SELECT 2", + createdAt: Date(timeIntervalSinceReferenceDate: 200), + updatedAt: Date(timeIntervalSinceReferenceDate: 300) + ) + + let outcome = merge(SavedQueryContents(queries: [stored]), SavedQueryRemoteChanges(changedQueries: [pulled])) + + let merged = try #require(outcome.contents.queries.first) + #expect(merged.name == "New") + #expect(merged.query == "SELECT 2") + #expect(merged.createdAt == stored.createdAt) + #expect(merged.updatedAt == pulled.updatedAt) + } + + @Test("The older query keeps a keyword contested in one connection, and a global holder is never contested") + func olderQueryKeepsTheKeyword() { + let younger = SavedQuery( + name: "Younger", + query: "SELECT 1", + keyword: "one", + connectionId: connectionId, + createdAt: Date(timeIntervalSinceReferenceDate: 200) + ) + let global = SavedQuery(name: "Global", query: "SELECT 2", keyword: "one", createdAt: Date(timeIntervalSinceReferenceDate: 50)) + let older = SavedQuery( + name: "Older", + query: "SELECT 3", + keyword: "one", + connectionId: connectionId, + createdAt: Date(timeIntervalSinceReferenceDate: 100) + ) + + let outcome = merge( + SavedQueryContents(queries: [younger, global]), + SavedQueryRemoteChanges(changedQueries: [older]) + ) + + #expect(outcome.contents.query(older.id)?.keyword == "one") + #expect(outcome.contents.query(global.id)?.keyword == "one") + #expect(outcome.contents.query(younger.id)?.keyword == nil) + #expect(outcome.releasedKeywordIds == [younger.id]) + } + + @Test("A deleted folder hands its queries and subfolders to its parent, and a deleted query goes") + func deletionsApplyAsOnTheMac() { + let parent = SavedQueryFolder(name: "Parent") + let folder = SavedQueryFolder(name: "Folder", parentId: parent.id) + let child = SavedQueryFolder(name: "Child", parentId: folder.id) + let inside = SavedQuery(name: "Inside", query: "SELECT 1", folderId: folder.id) + let removed = SavedQuery(name: "Removed", query: "SELECT 2") + let local = SavedQueryContents(queries: [inside, removed], folders: [parent, folder, child]) + + let outcome = merge(local, SavedQueryRemoteChanges(deletedQueryIds: [removed.id], deletedFolderIds: [folder.id])) + + let folderIds = outcome.contents.folders.map(\.id) + let queryIds = outcome.contents.queries.map(\.id) + #expect(folderIds == [parent.id, child.id]) + #expect(queryIds == [inside.id]) + #expect(outcome.contents.folder(child.id)?.parentId == parent.id) + #expect(outcome.contents.query(inside.id)?.folderId == parent.id) + #expect(outcome.releasedKeywordIds.isEmpty) + } + + @Test("A query and folder moved to the sample ignore edits and deletions of their old iCloud copies") + func keptLocalRecordsIgnoreTheirOldCopies() { + let sampleId = UUID() + let folder = SavedQueryFolder(name: "Sample", connectionId: sampleId) + let query = SavedQuery(name: "Moved", query: "SELECT 1", folderId: folder.id, connectionId: sampleId) + var stale = query + stale.connectionId = nil + stale.folderId = nil + let local = SavedQueryContents(queries: [query], folders: [folder]) + + let echoed = merge( + local, + SavedQueryRemoteChanges(deletedQueryIds: [query.id], deletedFolderIds: [folder.id]), + keptLocalOwners: [sampleId] + ) + let overwritten = merge(local, SavedQueryRemoteChanges(changedQueries: [stale]), keptLocalOwners: [sampleId]) + + #expect(echoed.contents == local) + #expect(overwritten.contents == local) + } + + @Test("Applying the same batch twice changes nothing the second time, so a held token can replay it") + func replayIsIdempotent() { + let folder = SavedQueryFolder(name: "Reports", connectionId: connectionId) + let query = SavedQuery(name: "One", query: "SELECT 1", keyword: "one", folderId: folder.id, connectionId: connectionId) + let remote = SavedQueryRemoteChanges(changedQueries: [query], changedFolders: [folder]) + + let first = merge(SavedQueryContents(), remote) + let second = merge(first.contents, remote) + + #expect(first.contents == SavedQueryContents(queries: [query], folders: [folder])) + #expect(second.contents == first.contents) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Sync/SyncedWriteFailureTests.swift b/apple/TableProMobile/TableProMobileTests/Sync/SyncedWriteFailureTests.swift new file mode 100644 index 0000000000..c1b30e9314 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Sync/SyncedWriteFailureTests.swift @@ -0,0 +1,131 @@ +import CloudKit +import Foundation +@testable import TableProMobile +import TableProModels +import TableProSync +import TableProSyncTransport +import Testing + +@MainActor +struct SyncedWriteFailureTests { + private let fixture: AppStateFixture + private let zoneID = CKRecordZone.ID(zoneName: "RecordingZone", ownerName: CKCurrentUserDefaultName) + private let tokenKey = "com.TablePro.sync.serverChangeToken" + + init() throws { + fixture = try AppStateFixture() + } + + private var savedToken: Data? { + fixture.metadata.userDefaults.data(forKey: tokenKey) + } + + // A directory the app cannot write into makes every library save fail the way a full disk does. + private func setLibraryWritable(_ writable: Bool) throws { + try FileManager.default.setAttributes( + [.posixPermissions: writable ? 0o755 : 0o555], + ofItemAtPath: fixture.libraryDirectory.path + ) + } + + private func makeSyncedState(transport: RecordingSyncTransport) -> AppState { + let state = fixture.makeState(syncEnabled: true, transport: transport) + state.connectSync() + return state + } + + @Test("A pulled connection that could not be saved is not shown, and the change token waits for it") + func unsavedConnectionHoldsTheToken() async throws { + let remote = DatabaseConnection(name: "From Mac", type: .postgresql) + let transport = RecordingSyncTransport( + remoteRecords: [SyncRecordMapper.toRecord(remote, zoneID: zoneID)], + newToken: try RecordingSyncTransport.makeChangeToken() + ) + let state = makeSyncedState(transport: transport) + try setLibraryWritable(false) + defer { try? setLibraryWritable(true) } + + await state.syncCoordinator.sync() + + #expect(state.connections.isEmpty) + #expect(savedToken == nil) + #expect(state.syncCoordinator.status == .error(.pullNotSaved)) + + try setLibraryWritable(true) + await state.syncCoordinator.sync() + + let shown = state.connections.map(\.id) + let stored = fixture.makeState(syncEnabled: false).connections.map(\.id) + #expect(shown == [remote.id]) + #expect(stored == [remote.id]) + #expect(savedToken != nil) + } + + @Test("A pulled saved query that could not be saved holds only the token: the cache moves and connections go up") + func unsavedSavedQueryHoldsOnlyTheToken() async throws { + let query = SavedQuery(name: "One", query: "SELECT 1") + let record = SyncRecordMapper.toRecord(query, zoneID: zoneID) + let transport = RecordingSyncTransport(remoteRecords: [record], newToken: try RecordingSyncTransport.makeChangeToken()) + let state = makeSyncedState(transport: transport) + let local = DatabaseConnection(name: "Local", type: .mysql) + #expect(state.addConnection(local)) + try setLibraryWritable(false) + defer { try? setLibraryWritable(true) } + + await state.syncCoordinator.sync() + + #expect(state.savedQueries.contents.queries.isEmpty) + #expect(savedToken == nil) + #expect(state.syncCoordinator.status == .error(.pullNotSaved)) + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toConnection).map(\.id) + #expect(pushed == [local.id]) + let cache = SyncRecordCache(directory: fixture.root.appendingPathComponent("Cache"), defaults: nil) + #expect(cache.record(for: record.recordID) != nil) + + try setLibraryWritable(true) + await state.syncCoordinator.sync() + + let stored = state.savedQueries.contents.queries.map(\.id) + #expect(stored == [query.id]) + #expect(savedToken != nil) + #expect(state.syncCoordinator.status == .idle) + } + + @Test("With the saved queries file unreadable, connections still sync, the file is kept and the token waits") + func unreadableSavedQueriesHoldOnlyTheToken() async throws { + let file = fixture.libraryDirectory.appendingPathComponent("saved-queries.json") + let unreadable = Data("{ not json".utf8) + try unreadable.write(to: file) + let query = SavedQuery(name: "One", query: "SELECT 1") + let transport = RecordingSyncTransport( + remoteRecords: [SyncRecordMapper.toRecord(query, zoneID: zoneID)], + newToken: try RecordingSyncTransport.makeChangeToken() + ) + let state = makeSyncedState(transport: transport) + let local = DatabaseConnection(name: "Local", type: .mysql) + #expect(state.addConnection(local)) + let marked = SavedQuery(name: "Marked", query: "SELECT 2") + state.syncCoordinator.markDirty(savedQueries: [marked.id]) + state.syncCoordinator.markDeleted(savedQueries: [UUID()]) + + await state.syncCoordinator.sync() + + #expect(state.loadStatus == .ready) + #expect(state.savedQueries.status == .failed) + #expect(try Data(contentsOf: file) == unreadable) + #expect(savedToken == nil) + let pushedTypes = Set(await transport.pushedRecords.map(\.recordType)) + #expect(pushedTypes == [SyncRecordType.connection.rawValue]) + #expect(await transport.pushedDeletions.isEmpty) + #expect(state.syncCoordinator.status == .error(.pullNotSaved)) + + try JSONEncoder().encode(SavedQueryContents()).write(to: file) + state.retryLoadIfFailed() + await state.syncCoordinator.sync() + + #expect(state.savedQueries.status == .ready) + let stored = state.savedQueries.contents.queries.map(\.id) + #expect(stored == [query.id]) + #expect(savedToken != nil) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Views/SQLEditorHandleTests.swift b/apple/TableProMobile/TableProMobileTests/Views/SQLEditorHandleTests.swift index 10a1715c41..a8812832e3 100644 --- a/apple/TableProMobile/TableProMobileTests/Views/SQLEditorHandleTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Views/SQLEditorHandleTests.swift @@ -157,6 +157,20 @@ struct SQLEditorHandleTests { #expect(editor.bandedRanges() == [NSRange(location: 0, length: 9)]) } + @Test("A saved query opened into the editor runs on Run, though its SQL ends in a newline") + func openedSavedQueryRuns() async throws { + let editor = try HostedEditor(text: "") + defer { editor.tearDown() } + + editor.handle.noteNothingToRun() + await ObservedCondition.wait { editor.handle.runNote != nil } + + editor.model.text = SavedQueryDraft.editorText(opening: "SELECT 1;\n") + await ObservedCondition.wait { editor.handle.runNote == nil } + #expect(editor.handle.runTarget() == .statement("SELECT 1;", range: NSRange(location: 0, length: 9))) + #expect(editor.bandedRanges() == [NSRange(location: 0, length: 9)]) + } + private func nextMainQueueTurn() async { await withCheckedContinuation { continuation in DispatchQueue.main.async { continuation.resume() } diff --git a/apple/TableProTests/Core/Sync/SyncCategoryRefetchTests.swift b/apple/TableProTests/Core/Sync/SyncCategoryRefetchTests.swift new file mode 100644 index 0000000000..26aed2c215 --- /dev/null +++ b/apple/TableProTests/Core/Sync/SyncCategoryRefetchTests.swift @@ -0,0 +1,218 @@ +import CloudKit +import Foundation +@testable import TablePro +import TableProSyncTransport +import TableProTestSupport +import Testing + +@MainActor +struct SyncCategoryRefetchTests { + private let environment: SyncTestEnvironment + + init() throws { + environment = try SyncTestEnvironment(label: "sync-category-refetch") + } + + @Test("Every category on reads what the build reads, and a category off drops its record types") + func readableContentFollowsTheCategories() { + var settings = SyncSettings.default + #expect(SyncCoordinator.readableContent(for: settings) == SyncCoordinator.readableContent) + + settings.syncSQLFavorites = false + settings.syncSettings = false + let content = SyncCoordinator.readableContent(for: settings) + + #expect(!content.contains(SyncRecordType.favorite.rawValue)) + #expect(!content.contains(SyncRecordType.favoriteFolder.rawValue)) + #expect(!content.contains(SyncRecordType.settings.rawValue)) + #expect(!content.contains(SyncCoordinator.themeContentKind)) + #expect(content.contains(SyncRecordType.connection.rawValue)) + } + + @Test("Turning Saved Queries on fetches the zone once and brings back a query saved elsewhere while it was off") + func turningACategoryOnRefetches() async throws { + let favorite = SQLFavorite(name: "Revenue", query: "SELECT 1") + let server = ZoneServer( + zoneID: SyncTestEnvironment.zoneID, + records: [SyncRecordMapper.toCKRecord(sqlFavorite: favorite, in: SyncTestEnvironment.zoneID)], + token: try Self.makeChangeToken() + ) + _ = environment.metadata.adoptReadableRecordTypes( + SyncCoordinator.readableContent(for: saveSync(savedQueries: false)) + ) + let coordinator = makeCoordinator(server: server) + + #expect(await coordinator.runSyncCycle() == nil) + #expect(await environment.favorites.fetchFavorite(id: favorite.id) == nil) + #expect(environment.metadata.loadToken() != nil) + + saveSync(savedQueries: true) + #expect(await coordinator.runSyncCycle() == nil) + #expect(await environment.favorites.fetchFavorite(id: favorite.id)?.name == "Revenue") + + #expect(await coordinator.runSyncCycle() == nil) + let pulls = await server.pulls + #expect(pulls == [.full, .full, .delta]) + } + + @Test("Saved Queries off keeps the change token at launch and at the next launch") + func categoryOffKeepsTheTokenAcrossRelaunch() async throws { + #expect(environment.connections.saveConnections([DatabaseConnection(name: "Local")])) + _ = environment.metadata.adoptReadableRecordTypes(SyncCoordinator.readableContent) + let token = try Self.makeChangeToken() + environment.metadata.saveToken(token) + saveSync(savedQueries: false) + let server = ZoneServer(zoneID: SyncTestEnvironment.zoneID, records: [], token: token) + + let coordinator = makeCoordinator(server: server) + coordinator.start() + #expect(environment.metadata.loadToken() != nil) + try await waitUntil { await server.pulls.count >= 1 && coordinator.syncStatus != .syncing } + + let relaunched = makeCoordinator(server: server) + relaunched.start() + #expect(environment.metadata.loadToken() != nil) + try await waitUntil { await server.pulls.count >= 2 && relaunched.syncStatus != .syncing } + + let pulls = await server.pulls + #expect(!pulls.contains(.full)) + #expect(environment.metadata.loadToken() != nil) + } + + @Test("Saved Queries turned off during a download keeps what that download brought, so turning it on loses nothing") + func categoryTurnedOffMidPullKeepsTheBatch() async throws { + let favorite = SQLFavorite(name: "Revenue", query: "SELECT 1") + let token = try Self.makeChangeToken() + _ = environment.metadata.adoptReadableRecordTypes( + SyncCoordinator.readableContent(for: saveSync(savedQueries: true)) + ) + environment.metadata.saveToken(token) + let turnedOff = Self.syncSettings(savedQueries: false) + let storage = AppSettingsStorage(userDefaults: environment.defaults) + let server = ZoneServer( + zoneID: SyncTestEnvironment.zoneID, + records: [], + token: token, + firstDelta: [SyncRecordMapper.toCKRecord(sqlFavorite: favorite, in: SyncTestEnvironment.zoneID)], + duringPull: { storage.saveSync(turnedOff) } + ) + let coordinator = makeCoordinator(server: server) + + #expect(await coordinator.runSyncCycle() == nil) + saveSync(savedQueries: true) + #expect(await coordinator.runSyncCycle() == nil) + + #expect(await environment.favorites.fetchFavorite(id: favorite.id)?.name == "Revenue") + } + + @discardableResult + private func saveSync(savedQueries: Bool) -> SyncSettings { + let settings = Self.syncSettings(savedQueries: savedQueries) + AppSettingsStorage(userDefaults: environment.defaults).saveSync(settings) + return settings + } + + private static func syncSettings(savedQueries: Bool) -> SyncSettings { + var settings = SyncSettings.default + settings.enabled = true + settings.syncSQLFavorites = savedQueries + return settings + } + + private func makeCoordinator(server: ZoneServer) -> SyncCoordinator { + let base = environment.makeCoordinator(transport: ScriptedSyncTransport(zoneID: SyncTestEnvironment.zoneID)) + let columnLayouts = environment.columnLayouts + return SyncCoordinator( + services: base.services, + recordCache: environment.recordCache, + transport: server, + makeNetworkMonitor: { nil }, + columnLayouts: columnLayouts, + licenseGate: { nil } + ) + } + + private static func makeChangeToken() throws -> CKServerChangeToken { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + archiver.setClassName("CKServerChangeToken", for: CategoryRefetchChangeTokenArchive.self) + archiver.encode(CategoryRefetchChangeTokenArchive(), forKey: NSKeyedArchiveRootObjectKey) + archiver.finishEncoding() + let token = try NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: archiver.encodedData) + return try #require(token) + } +} + +// A delta holds only what is new since the token, as CloudKit answers: `firstDelta` once, then nothing. +private actor ZoneServer: SyncTransport { + enum Pull: Equatable { + case full + case delta + } + + let currentZoneID: CKRecordZone.ID + private let zoneRecords: [CKRecord] + private let token: CKServerChangeToken + private var firstDelta: [CKRecord] + private let duringPull: @Sendable () -> Void + private(set) var pulls: [Pull] = [] + + init( + zoneID: CKRecordZone.ID, + records: [CKRecord], + token: CKServerChangeToken, + firstDelta: [CKRecord] = [], + duringPull: @escaping @Sendable () -> Void = {} + ) { + currentZoneID = zoneID + zoneRecords = records + self.token = token + self.firstDelta = firstDelta + self.duringPull = duringPull + } + + func accountStatus() async throws -> CKAccountStatus { + .available + } + + func currentAccountId() async throws -> String { + ScriptedSyncTransport.accountId + } + + func ensureZoneExists() async throws {} + + func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome { + var outcome = PushOutcome() + for record in records { + outcome.recordSave(record) + } + for recordID in deletions { + outcome.recordDeletion(recordID) + } + return outcome + } + + func pull(since token: CKServerChangeToken?) async throws -> PullResult { + pulls.append(token == nil ? .full : .delta) + duringPull() + guard token != nil else { + return PullResult(changedRecords: zoneRecords, deletedRecordIDs: [], newToken: self.token) + } + defer { firstDelta = [] } + return PullResult(changedRecords: firstDelta, deletedRecordIDs: [], newToken: self.token) + } +} + +@objc(SyncCategoryRefetchTestsChangeTokenArchive) +private final class CategoryRefetchChangeTokenArchive: NSObject, NSSecureCoding { + static var supportsSecureCoding: Bool { true } + + override init() { + super.init() + } + + required init?(coder: NSCoder) { + super.init() + } + + func encode(with coder: NSCoder) {} +} diff --git a/apple/changelog.d/3684.added.md b/apple/changelog.d/3684.added.md new file mode 100644 index 0000000000..79ccfc8288 --- /dev/null +++ b/apple/changelog.d/3684.added.md @@ -0,0 +1 @@ +Saved queries on iPhone and iPad, in folders, synced with the Mac through iCloud and carried in .tablepro files. diff --git a/apple/changelog.d/3684.fixed.1.md b/apple/changelog.d/3684.fixed.1.md new file mode 100644 index 0000000000..22e6cda923 --- /dev/null +++ b/apple/changelog.d/3684.fixed.1.md @@ -0,0 +1 @@ +Changes from other devices lost on iPhone and iPad when saving them failed during a sync. diff --git a/apple/changelog.d/3684.fixed.2.md b/apple/changelog.d/3684.fixed.2.md new file mode 100644 index 0000000000..71458e5ead --- /dev/null +++ b/apple/changelog.d/3684.fixed.2.md @@ -0,0 +1 @@ +A connection, group or tag deleted on iPhone or iPad coming back from iCloud before the deletion was uploaded. diff --git a/apple/changelog.d/3684.fixed.md b/apple/changelog.d/3684.fixed.md new file mode 100644 index 0000000000..953d79a05a --- /dev/null +++ b/apple/changelog.d/3684.fixed.md @@ -0,0 +1 @@ +Turning a sync category back on not bringing down what other devices added or edited while it was off.