(_ block: () -> T) -> T {
28 | var result: T?
29 | performAndWait {
30 | result = block()
31 | }
32 | return result!
33 | }
34 | }
35 |
36 | public extension Task where Success == Never, Failure == Never {
37 | static func sleep(seconds duration: Double) async throws {
38 | try await sleep(nanoseconds: UInt64(duration * 1000000000))
39 | }
40 | }
41 |
42 | import Combine
43 | /// 将Publisher转换成异步序列。
44 | ///
45 | /// 同系统内置的 publisher.values 不同,本实现将首先对数据进行缓存。尤其适用于NotificationCenter之类的应用。
46 | struct CombineAsyncPublisher: AsyncSequence, AsyncIteratorProtocol where P: Publisher, P.Failure == Never {
47 | typealias Element = P.Output
48 | typealias AsyncIterator = CombineAsyncPublisher
49 |
50 | func makeAsyncIterator() -> Self {
51 | return self
52 | }
53 |
54 | private let stream: AsyncStream
55 | private var iterator: AsyncStream.Iterator
56 | private var cancellable: AnyCancellable?
57 |
58 | init(_ upstream: P, bufferingPolicy limit: AsyncStream.Continuation.BufferingPolicy = .unbounded) {
59 | var subscription: AnyCancellable?
60 | stream = AsyncStream(P.Output.self, bufferingPolicy: limit) { continuation in
61 | subscription = upstream
62 | .sink(receiveValue: { value in
63 | continuation.yield(value)
64 | })
65 | }
66 | cancellable = subscription
67 | iterator = stream.makeAsyncIterator()
68 | }
69 |
70 | mutating func next() async -> P.Output? {
71 | await iterator.next()
72 | }
73 | }
74 |
75 | extension Publisher where Self.Failure == Never {
76 | var sequence: CombineAsyncPublisher {
77 | CombineAsyncPublisher(self)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/Fetcher.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Fetcher.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/11
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | import Foundation
15 |
16 | /// 获取从指定时期之后的,非当前author生成的 transaction
17 | struct Fetcher: TransactionFetcherProtocol {
18 | init(backgroundContext: NSManagedObjectContext,
19 | currentAuthor: String,
20 | allAuthors: [String],
21 | includingCloudKitMirroring: Bool = false) {
22 | self.backgroundContext = backgroundContext
23 | self.currentAuthor = currentAuthor
24 | if includingCloudKitMirroring {
25 | self.allAuthors = Array(Set(allAuthors + Self.cloudMirrorAuthors))
26 | } else {
27 | self.allAuthors = Array(Set(allAuthors))
28 | }
29 | }
30 |
31 | var backgroundContext: NSManagedObjectContext
32 | var currentAuthor: String
33 | var allAuthors: [String]
34 |
35 | /// 获取所有不是当前 author 产生的 transaction
36 | /// - Parameter date: 从该日期之后产生
37 | /// - Returns:[NSPersistentHistoryTransaction]
38 | func fetchTransactions(from date: Date) throws -> [NSPersistentHistoryTransaction] {
39 | try backgroundContext.performAndWait {
40 | let historyChangeRequest = createHistoryChangeRequest(from: date)
41 | let historyResult = try backgroundContext.execute(historyChangeRequest) as? NSPersistentHistoryResult
42 | return historyResult?.result as? [NSPersistentHistoryTransaction] ?? []
43 | }
44 | }
45 |
46 | /// 生成 NSPersistentHistoryChangeRequest。
47 | /// 所有不是当前 author 产生的 transaction。
48 | /// - Parameter date: 获取从该日期之后产生的 transaction
49 | /// - Returns: NSPersistentHistoryChangeRequest
50 | func createHistoryChangeRequest(from date: Date) -> NSPersistentHistoryChangeRequest {
51 | let historyChangeRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: date)
52 | if let fetchRequest = NSPersistentHistoryTransaction.fetchRequest {
53 | fetchRequest.predicate = createPredicateForOtherAuthors(currentAuthor: currentAuthor, allAuthors: allAuthors)
54 | historyChangeRequest.fetchRequest = fetchRequest
55 | }
56 | return historyChangeRequest
57 | }
58 |
59 | /// 创建排除当前author的查询谓词
60 | func createPredicateForOtherAuthors(currentAuthor: String, allAuthors: [String]) -> NSPredicate {
61 | var predicates = [NSPredicate]()
62 | for author in allAuthors where author != currentAuthor {
63 | let predicate = NSPredicate(format: "%K = %@",
64 | #keyPath(NSPersistentHistoryTransaction.author),
65 | author)
66 | predicates.append(predicate)
67 | }
68 | let compoundPredicate = NSCompoundPredicate(type: .or, subpredicates: predicates)
69 | return compoundPredicate
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/Merger.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Merger.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/12
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | import Foundation
15 |
16 | struct Merger: TransactionMergerProtocol {
17 | func callAsFunction(merge transactions: [NSPersistentHistoryTransaction],
18 | into contexts: [NSManagedObjectContext]) {
19 | for transaction in transactions {
20 | let userInfo = transaction.objectIDNotification().userInfo ?? [:]
21 | NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: contexts)
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/PersistentHistoryTrackingKit.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PersistentHistoryTrackingKit.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/11
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | import Foundation
15 | import AsyncAlgorithms
16 |
17 | // swiftlint:disable line_length
18 |
19 | public final class PersistentHistoryTrackingKit {
20 | /// 日志显示等级,从0-2级。0 关闭 2 最详尽
21 | public var logLevel: Int
22 |
23 | /// 清除策略
24 | var strategy: TransactionPurgePolicy
25 |
26 | /// 当前 transaction 的 author
27 | let currentAuthor: String
28 |
29 | /// 全部的 authors (包括app group当中所有使用同一数据库的成员以及用于批量操作的author)
30 | let allAuthors: [String]
31 |
32 | /// 是否合并由 NSPersistentCloudContainer 导入的网络数据
33 | /// 如果你直接在 NSPersistentCloudContainer 上使用 Persistent History Tracking ,可以直接使用默认值 false,此时,NSPersistentCloudContainer 将自动处理合并事宜
34 | /// 此选项通常用于 NSPersistentContainer 之上,将另一个 CloudContainer 导入的数据合并到当前的 container 的 viewContext 中。
35 | let includingCloudKitMirroring: Bool
36 |
37 | /// 用于批量操作的 authors
38 | ///
39 | /// 由于批量操作的 author 只会生成 transaction,并不会对其他 author 产生的 transaction 进行合并和清除。
40 | /// 仅此此类 auhtors 最好可以单独标注出来,这样其他的 authors 在清除时将不会为其保留不必要的 transaction。
41 | /// 即使不单独设置,当遗留的 transaction 满足 maximumDuration 后,仍会被自动清除。
42 | let batchAuthors: [String]
43 |
44 | /// 需要被合并的上下文,通常是视图上下文。可以有多个
45 | let contexts: [NSManagedObjectContext]
46 |
47 | /// transaction 最长可以保存的时间(秒)。如果在该时间内仍无法获取到全部的 author 更新时间戳,
48 | /// 将返回从当前时间减去该秒数的日期 Date().addingTimeInterval(-1 * abs(maximumDuration))
49 | let maximumDuration: TimeInterval
50 |
51 | /// 在 UserDefaults 中保存时间戳 Key 的前缀。
52 | let uniqueString: String
53 |
54 | /// 日志管理器
55 | let logger: PersistentHistoryTrackingKitLoggerProtocol
56 |
57 | /// 获取需要处理的 transaction
58 | let fetcher: Fetcher
59 |
60 | /// 合并transaction到指定的托管对象上下文中(contexts)
61 | let merger: Merger
62 |
63 | /// 删除transaction中重复数据
64 | let deduplicator: TransactionDeduplicatorProtocol?
65 |
66 | /// transaction清除器,清除可确认的已被所有authors合并的transaction
67 | let cleaner: Cleaner
68 |
69 | /// 时间戳管理器,过去并更新合并事件戳
70 | let timestampManager: TransactionTimestampManager
71 |
72 | /// 处理持久化历史跟踪事件的任务。可以通过start开启,stop停止。
73 | var transactionProcessingTasks = [Task]()
74 |
75 | /// 持久化存储协调器,用于缩小通知返回
76 | private let coordinator: NSPersistentStoreCoordinator
77 | /// 专职处理transaction的托管对象上下文
78 | private let backgroundContext: NSManagedObjectContext
79 |
80 | /// 创建处理 Transaction 的任务。
81 | ///
82 | /// 通过将持久化历史跟踪记录的通知转换成异步序列,实现了逐个处理的机制。
83 | func createTransactionProcessingTask() -> Task {
84 | Task {
85 | sendMessage(type: .info, level: 1, message: "Persistent History Track Kit Start")
86 | // 响应 notification
87 | let publisher = NotificationCenter.default.publisher(
88 | for: .NSPersistentStoreRemoteChange,
89 | object: coordinator
90 | )
91 | for await _ in publisher.values.buffer(policy: .unbounded) where !Task.isCancelled {
92 | sendMessage(type: .info,
93 | level: 2,
94 | message: "Get a `NSPersistentStoreRemoteChange` notification")
95 |
96 | // fetch
97 | let transactions = fetchTransactions(
98 | for: currentAuthor,
99 | since: timestampManager,
100 | by: fetcher,
101 | logger: sendMessage
102 | )
103 |
104 | if transactions.isEmpty { continue }
105 |
106 | // merge
107 | mergeTransactionsInContexts(
108 | transactions: transactions,
109 | by: merger,
110 | timestampManager: timestampManager,
111 | logger: sendMessage
112 | )
113 |
114 | deduplicator?(deduplicate: transactions, in: contexts)
115 |
116 | // clean
117 | cleanTransactions(
118 | beforeDate: timestampManager,
119 | allAuthors: allAuthors,
120 | batchAuthors: batchAuthors,
121 | by: cleaner,
122 | logger: sendMessage
123 | )
124 | }
125 | sendMessage(type: .info, level: 1, message: "Persistent History Track Kit Stop")
126 | }
127 | }
128 |
129 | /// get all new transactions since the last merge date
130 | func fetchTransactions(
131 | for currentAuthor: String,
132 | since lastTimestampManager: TransactionTimestampManagerProtocol,
133 | by fetcher: TransactionFetcherProtocol,
134 | logger: Logger?
135 | ) -> [NSPersistentHistoryTransaction] {
136 | let lastTimestamp = lastTimestampManager
137 | .getLastHistoryTransactionTimestamp(for: currentAuthor) ?? Date.distantPast
138 | logger?(.info, 2,
139 | "The last history transaction timestamp for \(allAuthors) is \(Self.dateFormatter.string(from: lastTimestamp))")
140 | var transactions = [NSPersistentHistoryTransaction]()
141 | do {
142 | transactions = try fetcher.fetchTransactions(from: lastTimestamp)
143 | let changesCount = transactions
144 | .map { $0.changes?.count ?? 0 }
145 | .reduce(0, +)
146 | let message = "There are \(transactions.count) transactions with \(changesCount) changes related to `\(currentAuthor)` in the query"
147 | logger?(.info, 2, message)
148 | } catch {
149 | logger?(.error, 1, "Fetch transaction error: \(error.localizedDescription)")
150 | }
151 | return transactions
152 | }
153 |
154 | /// merge transactions in contexts
155 | func mergeTransactionsInContexts(
156 | transactions: [NSPersistentHistoryTransaction],
157 | by merger: TransactionMergerProtocol,
158 | timestampManager: TransactionTimestampManagerProtocol,
159 | logger: Logger?
160 | ) {
161 | guard let lastTimestamp = transactions.last?.timestamp else { return }
162 | merger(merge: transactions, into: contexts)
163 | timestampManager.updateLastHistoryTransactionTimestamp(for: currentAuthor, to: lastTimestamp)
164 | let message = "merge \(transactions.count) transactions, update `\(currentAuthor)`'s timestamp to \(Self.dateFormatter.string(from: lastTimestamp))"
165 | logger?(.info, 2, message)
166 | }
167 |
168 | /// clean up all transactions that has been merged by all contexts
169 | func cleanTransactions(
170 | beforeDate timestampManager: TransactionTimestampManagerProtocol,
171 | allAuthors: [String],
172 | batchAuthors: [String],
173 | by cleaner: TransactionCleanerProtocol,
174 | logger: Logger?
175 | ) {
176 | guard strategy.allowedToClean() else { return }
177 | let cleanTimestamp = timestampManager.getLastCommonTransactionTimestamp(in: allAuthors, exclude: batchAuthors)
178 | do {
179 | try cleaner.cleanTransaction(before: cleanTimestamp)
180 | logger?(.info, 2, "Delete transaction success")
181 | } catch {
182 | logger?(.error, 1, "Delete transaction error: \(error.localizedDescription)")
183 | }
184 | }
185 |
186 | typealias Logger = (PersistentHistoryTrackingKitLogType, Int, String) -> Void
187 |
188 | /// 发送日志
189 | func sendMessage(type: PersistentHistoryTrackingKitLogType, level: Int, message: String) {
190 | guard level <= logLevel else { return }
191 | logger.log(type: type, message: message)
192 | }
193 |
194 | init(logLevel: Int,
195 | strategy: TransactionCleanStrategy,
196 | deduplicator: TransactionDeduplicatorProtocol?,
197 | currentAuthor: String,
198 | allAuthors: [String],
199 | includingCloudKitMirroring: Bool,
200 | batchAuthors: [String],
201 | viewContext: NSManagedObjectContext,
202 | contexts: [NSManagedObjectContext],
203 | userDefaults: UserDefaults,
204 | maximumDuration: TimeInterval,
205 | uniqueString: String,
206 | logger: PersistentHistoryTrackingKitLoggerProtocol,
207 | autoStart: Bool) {
208 | self.logLevel = logLevel
209 | self.currentAuthor = currentAuthor
210 | self.allAuthors = allAuthors
211 | self.includingCloudKitMirroring = includingCloudKitMirroring
212 | self.batchAuthors = batchAuthors
213 | self.contexts = contexts
214 | self.maximumDuration = maximumDuration
215 | self.uniqueString = uniqueString
216 | self.logger = logger
217 |
218 | // 检查 viewContext 是否为视图上下文
219 | guard viewContext.concurrencyType == .mainQueueConcurrencyType else {
220 | fatalError("`viewContext` must be a view context ( concurrencyType == .mainQueueConcurrencyType)")
221 | }
222 |
223 | guard let coordinator = viewContext.persistentStoreCoordinator else {
224 | fatalError("`viewContext` must have a persistentStoreCoordinator available")
225 | }
226 |
227 | let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
228 | backgroundContext.persistentStoreCoordinator = coordinator
229 |
230 | switch strategy {
231 | case .none:
232 | self.strategy = TransactionCleanStrategyNone()
233 | case .byDuration:
234 | self.strategy = TransactionCleanStrategyByDuration(strategy: strategy)
235 | case .byNotification:
236 | self.strategy = TransactionCleanStrategyByNotification(strategy: strategy)
237 | }
238 |
239 | self.fetcher = Fetcher(
240 | backgroundContext: backgroundContext,
241 | currentAuthor: currentAuthor,
242 | allAuthors: allAuthors,
243 | includingCloudKitMirroring: includingCloudKitMirroring
244 | )
245 |
246 | self.merger = Merger()
247 | self.deduplicator = deduplicator
248 | self.cleaner = Cleaner(backgroundContext: backgroundContext, authors: allAuthors)
249 | self.timestampManager = TransactionTimestampManager(userDefaults: userDefaults, maximumDuration: maximumDuration, uniqueString: uniqueString)
250 | self.coordinator = coordinator
251 | self.backgroundContext = backgroundContext
252 |
253 | if autoStart {
254 | start()
255 | }
256 | }
257 |
258 | deinit {
259 | stop()
260 | }
261 | }
262 |
263 | public extension PersistentHistoryTrackingKit {
264 | /// 启动处理任务
265 | func start() {
266 | guard transactionProcessingTasks.isEmpty else {
267 | return
268 | }
269 | transactionProcessingTasks.append(createTransactionProcessingTask())
270 | }
271 |
272 | /// 停止处理任务
273 | func stop() {
274 | transactionProcessingTasks.forEach {
275 | $0.cancel()
276 | }
277 | transactionProcessingTasks.removeAll()
278 | }
279 | }
280 |
281 | extension PersistentHistoryTrackingKit {
282 | static let dateFormatter: DateFormatter = {
283 | let formatter = DateFormatter()
284 | formatter.dateStyle = .short
285 | formatter.timeStyle = .medium
286 | return formatter
287 | }()
288 | }
289 |
290 | public extension PersistentHistoryTrackingKit {
291 | /// 创建一个可独立运行的 transaction 清除器
292 | ///
293 | /// 通常使用该清除器时,cleanStrategy 应设置为 .none
294 | /// 在 PersistentHistoryTrackKit 中使用 cleanerBuilder() 来生成该实例。该清理器的配置继承于 Kit 实例
295 | ///
296 | /// let kit = PersistentHistoryTrackKit(.....)
297 | /// let cleaner = kit().cleanerBuilder
298 | ///
299 | /// cleaner() //在需要执行清理的地方运行
300 | ///
301 | /// 比如每次app进入后台时,执行清理任务。
302 | func cleanerBuilder() -> PersistentHistoryTrackingKitManualCleaner {
303 | PersistentHistoryTrackingKitManualCleaner(
304 | clear: cleaner,
305 | timestampManager: timestampManager,
306 | logger: logger,
307 | logLevel: logLevel,
308 | authors: allAuthors
309 | )
310 | }
311 | }
312 |
313 | public extension PersistentHistoryTrackingKit {
314 | /// 使用viewContext的初始化器
315 | convenience init(viewContext: NSManagedObjectContext,
316 | contexts: [NSManagedObjectContext]? = nil,
317 | currentAuthor: String,
318 | allAuthors: [String],
319 | includingCloudKitMirroring: Bool = false,
320 | batchAuthors: [String] = [],
321 | userDefaults: UserDefaults,
322 | cleanStrategy: TransactionCleanStrategy = .byNotification(times: 1),
323 | deduplicator: TransactionDeduplicatorProtocol? = nil,
324 | maximumDuration: TimeInterval = 60 * 60 * 24 * 7,
325 | uniqueString: String = "PersistentHistoryTrackingKit.lastToken.",
326 | logger: PersistentHistoryTrackingKitLoggerProtocol? = nil,
327 | logLevel: Int = 1,
328 | autoStart: Bool = true) {
329 | let contexts = contexts ?? [viewContext]
330 | let logger = logger ?? DefaultLogger()
331 | self.init(logLevel: logLevel,
332 | strategy: cleanStrategy,
333 | deduplicator: deduplicator,
334 | currentAuthor: currentAuthor,
335 | allAuthors: allAuthors,
336 | includingCloudKitMirroring: includingCloudKitMirroring,
337 | batchAuthors: batchAuthors,
338 | viewContext: viewContext,
339 | contexts: contexts,
340 | userDefaults: userDefaults,
341 | maximumDuration: maximumDuration,
342 | uniqueString: uniqueString,
343 | logger: logger,
344 | autoStart: autoStart)
345 | }
346 |
347 | /// 使用NSPersistentContainer的初始化器
348 | convenience init(container: NSPersistentContainer,
349 | contexts: [NSManagedObjectContext]? = nil,
350 | currentAuthor: String,
351 | allAuthors: [String],
352 | includingCloudKitMirroring: Bool = false,
353 | batchAuthors: [String] = [],
354 | userDefaults: UserDefaults,
355 | cleanStrategy: TransactionCleanStrategy = .byNotification(times: 1),
356 | deduplicator: TransactionDeduplicatorProtocol? = nil,
357 | maximumDuration: TimeInterval = 60 * 60 * 24 * 7,
358 | uniqueString: String = "PersistentHistoryTrackingKit.lastToken.",
359 | logger: PersistentHistoryTrackingKitLoggerProtocol? = nil,
360 | logLevel: Int = 1,
361 | autoStart: Bool = true) {
362 | let viewContext = container.viewContext
363 | let contexts = contexts ?? [viewContext]
364 | let logger = logger ?? DefaultLogger()
365 | self.init(logLevel: logLevel,
366 | strategy: cleanStrategy,
367 | deduplicator: deduplicator,
368 | currentAuthor: currentAuthor,
369 | allAuthors: allAuthors,
370 | includingCloudKitMirroring: includingCloudKitMirroring,
371 | batchAuthors: batchAuthors,
372 | viewContext: viewContext,
373 | contexts: contexts,
374 | userDefaults: userDefaults,
375 | maximumDuration: maximumDuration,
376 | uniqueString: uniqueString,
377 | logger: logger,
378 | autoStart: autoStart)
379 | }
380 | }
381 |
382 | extension Notification:@unchecked Sendable {}
383 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/Protocol/CleanerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CleanerProtocol.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/11
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | import Foundation
15 |
16 | public protocol TransactionCleanerProtocol {
17 | /// 用来提取Request和删除 transaction 的上下文。通常是私有上下文
18 | var backgroundContext: NSManagedObjectContext { get }
19 | /// 清理 transactions 时只处理 transactionAuthor 在该数组中的 transaction
20 | var authors: [String] { get }
21 | /// 清除指定时间之前由 authors 产生的 transaction
22 | func cleanTransaction(before timestamp: Date?) throws
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/Protocol/DeduplicatorProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeduplicatorProtocol.swift
3 | //
4 | //
5 | // Created by Yang Yubo on 2024/6/18.
6 | //
7 |
8 | import CoreData
9 | import Foundation
10 |
11 | public protocol TransactionDeduplicatorProtocol {
12 | /// 将 transaction 中的重复数据从托管对象上下文删除
13 | func callAsFunction(deduplicate transactions: [NSPersistentHistoryTransaction], in contexts: [NSManagedObjectContext])
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/Protocol/FetcherProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FetcherProtocol.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/11
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | import Foundation
15 |
16 | /// 获取从指定时期之后的,非当前author生成的 transaction
17 | protocol TransactionFetcherProtocol {
18 | /// 托管对象上下文。最好使用背景上下文
19 | var backgroundContext: NSManagedObjectContext { get }
20 | /// 当前的 author 名称。应用程序上下文的 transactionAuthor 需要与其一致
21 | var currentAuthor: String { get }
22 | /// 全部的 author 名称。在 app group 的情况下,每个app或app extension都使用各自的 author 名称
23 | /// 在同一个app下,对于批量添加的数据(batch insert),应该使用单独的 author,以区别。
24 | var allAuthors: [String] { get }
25 |
26 | /// 获取指定日期后的所有 Transaction。
27 | func fetchTransactions(from date: Date) throws -> [NSPersistentHistoryTransaction]
28 | }
29 |
30 | extension TransactionFetcherProtocol {
31 | static var cloudMirrorAuthors: [String] { ["NSCloudKitMirroringDelegate.import"] }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/Protocol/LoggerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoggerProtocol.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/10
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import Foundation
14 |
15 | /// 用于 PersistentHistoryTrackKit 的日志协议。
16 | /// 开发者可以创建符合该协议的类型,以便让 PersistentHistoryTrackKi t与你已有的日志模块协同工作。
17 | /// 日志输出的开关和细节控制均在 PersistentHistoryTrackKit 上
18 | public protocol PersistentHistoryTrackingKitLoggerProtocol {
19 | /// 输出日志。开发者可以将 LogType 转换成自己使用的日志模块对应的 Type
20 | func log(type: PersistentHistoryTrackingKitLogType, message: String)
21 | }
22 |
23 | /// 日志类型。尽管定义了5中类型,不过当前只会使用其中的 debug 和 error。
24 | public enum PersistentHistoryTrackingKitLogType: String {
25 | case debug, info, notice, error, fault
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/Protocol/MergerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MergerProtocol.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/12
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | import Foundation
15 |
16 | protocol TransactionMergerProtocol {
17 | /// 将 transaction 合并到指定的托管对象上下文。可以多个上下文,之间用 ,分隔
18 | func callAsFunction(merge transactions: [NSPersistentHistoryTransaction], into contexts: [NSManagedObjectContext])
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/Protocol/TransactionTimestampManagerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TransactionTimestampManagerProtocol.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/10
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import Foundation
14 |
15 | /// 保存和获取时间戳的管理协议。
16 | public protocol TransactionTimestampManagerProtocol {
17 | /// 从给定的 author 列表中,获取可以安全删除的时间戳。
18 | ///
19 | /// 如果给定了 exclude ,将仅对 authors - batchAuthors 的 author 进行时间判断
20 | /// Cleaner 将依据该时间戳 ,指示 Core Data 删除该时间戳之前的 Transaction。
21 | /// - Returns: 可以安全删除的日期。
22 | /// 当返回值为 nil 时,意味需要更新时间戳的 author 还没有全部更新
23 | func getLastCommonTransactionTimestamp(in authors: [String], exclude batchAuthors: [String]) -> Date?
24 | /// 更新指定 author 的最后更新日期
25 | /// 最后更新是指,该 author 对应的程序(app,app extension)已经在改时间戳完成了 Transaction 的合并工作
26 | func updateLastHistoryTransactionTimestamp(for author: String, to newDate: Date?)
27 | /// 获取指定的 author 的最后更新日期
28 | /// - Parameter author: author 是每个 app 或 app extension 的字符串名称。该名称应与NSManagedObjectContext的transactionAuthor一致
29 | /// - Returns: 该 author 的最后更新日期。如果该 author 尚未更新日期,则返回 nil
30 | func getLastHistoryTransactionTimestamp(for author: String) -> Date?
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/PersistentHistoryTrackingKit/TransactionTimestampManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TransactionTimestampManager.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/10
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import Foundation
14 |
15 | /// author 的 Transaction 合并更新的时间戳管理器。
16 | /// 本实现采用 UserDefaults 对每个 author 的最后更新日期进行保存,并从中返回可被安全删除的日期。
17 | /// 为了防止在 AppGroup 的情况下,部分 app 始终没有被启用或实现,从而导致数据不全的情况。
18 | /// 本实现设定了阈值日期机制,在满足了设定的情况下,将阈值日期作为可安全删除的日期返回
19 | struct TransactionTimestampManager: TransactionTimestampManagerProtocol {
20 | /// 用于保存的 UserDefaults 实例。对于 AppGroup,应该使用可用于全体成员的实例。如:UserDefaults(suiteName: Settings.AppGroup.groupID)
21 | private let userDefaults: UserDefaults
22 | /// transaction 最长可以保存的时间(秒)。如果在改时间内仍无法获取到全部的 author 更新时间戳,
23 | /// 将返回从当前时间剪去该秒数的日期 Date().addingTimeInterval(-1 * abs(maximumDuration))
24 | private let maximumDuration: TimeInterval
25 | /// 在 UserDefaults 中保存时间戳 Key 的前缀。
26 | private let uniqueString: String
27 |
28 | func getLastCommonTransactionTimestamp(in authors: [String], exclude batchAuthors: [String] = []) -> Date? {
29 | let shouldCheckAuthors = Set(authors).subtracting(batchAuthors)
30 | let lastTimestamps = shouldCheckAuthors
31 | .compactMap { author in
32 | getLastHistoryTransactionTimestamp(for: author)
33 | }
34 | // 没有任何author记录时间的情况下,直接返回nil
35 | let lastTimestamp = lastTimestamps.min() ?? Date().addingTimeInterval(-1 * abs(maximumDuration))
36 | return lastTimestamp
37 | }
38 |
39 | func updateLastHistoryTransactionTimestamp(for author: String, to newDate: Date?) {
40 | let key = uniqueString + author
41 | userDefaults.set(newDate, forKey: key)
42 | }
43 |
44 | /// 获取指定的 author 的最后更新日期
45 | /// - Parameter author: author 是每个 app 或 app extension 的字符串名称。该名称应与NSManagedObjectContext的transactionAuthor一致
46 | /// - Returns: 该 author 的最后更新日期。如果该 author 尚未更新日期,则返回 nil
47 | func getLastHistoryTransactionTimestamp(for author: String) -> Date? {
48 | let key = uniqueString + author
49 | return userDefaults.value(forKey: key) as? Date
50 | }
51 |
52 | /// 创建 author 的 Transaction 合并更新的时间戳管理器。
53 | /// - Parameters:
54 | /// - userDefaults: 用于保存的 UserDefaults 实例。
55 | /// 对于 AppGroup,应该使用可用于全体成员的实例。如:UserDefaults(suiteName: Settings.AppGroup.groupID)
56 | /// - maximumDuration: transaction 最长可以保存的时间(秒)。如果在改时间内仍无法获取到全部的 author 更新时间戳,
57 | /// 将返回从当前时间剪去该秒数的日期 Date().addingTimeInterval(-1 * abs(maximumDuration))。默认值为 604,800 秒(7日)。
58 | /// - uniqueString: 在 UserDefaults 中保存时间戳 Key 的前缀。默认值为:"PersistentHistoryTrackingKit.lastToken."
59 | init(userDefaults: UserDefaults,
60 | maximumDuration: TimeInterval = 60 * 60 * 24 * 7, // 7 days
61 | uniqueString: String = "PersistentHistoryTrackingKit.lastToken.") {
62 | self.userDefaults = userDefaults
63 | self.maximumDuration = maximumDuration
64 | self.uniqueString = uniqueString
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Tests/PersistentHistoryTrackingKitTests/CleanStrategyTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CleanStrategyTests.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/14
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | @testable import PersistentHistoryTrackingKit
14 | import XCTest
15 |
16 | class CleanStrategyTests: XCTestCase {
17 | func testNoneStrategy() {
18 | let strategy = TransactionCleanStrategyNone()
19 | XCTAssertFalse(strategy.allowedToClean())
20 | }
21 |
22 | func testByDuration() async throws {
23 | var strategy = TransactionCleanStrategyByDuration(strategy: .byDuration(seconds: 3))
24 | XCTAssertTrue(strategy.allowedToClean())
25 | await sleep(seconds: 2)
26 | XCTAssertFalse(strategy.allowedToClean())
27 | await sleep(seconds: 1.1)
28 | XCTAssertTrue(strategy.allowedToClean())
29 | XCTAssertFalse(strategy.allowedToClean())
30 | XCTAssertFalse(strategy.allowedToClean())
31 | await sleep(seconds: 3)
32 | XCTAssertTrue(strategy.allowedToClean())
33 | }
34 |
35 | func testByNotification() async throws {
36 | var strategyBy3 = TransactionCleanStrategyByNotification(strategy: .byNotification(times: 3))
37 | XCTAssertTrue(strategyBy3.allowedToClean())
38 |
39 | // 每三次执行一次
40 | XCTAssertFalse(strategyBy3.allowedToClean())
41 | XCTAssertFalse(strategyBy3.allowedToClean())
42 | XCTAssertTrue(strategyBy3.allowedToClean())
43 |
44 | XCTAssertFalse(strategyBy3.allowedToClean())
45 | XCTAssertFalse(strategyBy3.allowedToClean())
46 | XCTAssertTrue(strategyBy3.allowedToClean())
47 |
48 | // 每次都执行
49 | var strategyBy1 = TransactionCleanStrategyByNotification(strategy: .byNotification(times: 1))
50 | XCTAssertTrue(strategyBy1.allowedToClean())
51 | XCTAssertTrue(strategyBy1.allowedToClean())
52 | XCTAssertTrue(strategyBy1.allowedToClean())
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Tests/PersistentHistoryTrackingKitTests/CleanerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CleanerTests.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/12
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | @testable import PersistentHistoryTrackingKit
15 | import XCTest
16 |
17 | class CleanerTests: XCTestCase {
18 | let storeURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
19 | .first?
20 | .appendingPathComponent("PersistentHistoryTrackKitCleanTest.sqlite") ?? URL(fileURLWithPath: "")
21 |
22 | override func tearDown() async throws {
23 | await sleep(seconds: 2)
24 | try? FileManager.default.removeItem(at: storeURL)
25 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal"))
26 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm"))
27 | }
28 |
29 | func testCleanerInAppGroup() throws {
30 | // given
31 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
32 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
33 | let app1backgroundContext = container1.newBackgroundContext()
34 | let fetcher = Fetcher(backgroundContext: app1backgroundContext,
35 | currentAuthor: AppActor.app1.rawValue,
36 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue])
37 | let cleaner = Cleaner(
38 | backgroundContext: app1backgroundContext,
39 | authors: [AppActor.app1.rawValue, AppActor.app2.rawValue]
40 | )
41 |
42 | let app1viewContext = container1.viewContext
43 | app1viewContext.transactionAuthor = AppActor.app1.rawValue
44 |
45 | let app2viewContext = container2.viewContext
46 | app2viewContext.transactionAuthor = AppActor.app2.rawValue
47 |
48 | // when
49 | app1viewContext.performAndWait {
50 | let event = Event(context: app1viewContext)
51 | event.timestamp = Date()
52 | app1viewContext.saveIfChanged()
53 | }
54 |
55 | app2viewContext.performAndWait {
56 | let event = Event(context: app2viewContext)
57 | event.timestamp = Date()
58 | app2viewContext.saveIfChanged()
59 | }
60 |
61 | // then
62 | let transactionsBeforeClean = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2))
63 | XCTAssertEqual(transactionsBeforeClean.count, 1)
64 |
65 | try cleaner.cleanTransaction(before: Date())
66 |
67 | let transactionsAfterClean = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2))
68 | XCTAssertEqual(transactionsAfterClean.count, 0)
69 | }
70 |
71 | func testCleanerInBatchOperation() throws {
72 | guard #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) else {
73 | return
74 | }
75 | // given
76 | let container = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
77 | let viewContext = container.viewContext
78 | let batchContext = container.newBackgroundContext()
79 | let backgroundContext = container.newBackgroundContext()
80 |
81 | viewContext.transactionAuthor = AppActor.app1.rawValue
82 | batchContext.transactionAuthor = AppActor.app2.rawValue // 批量添加使用单独的author
83 |
84 | let fetcher = Fetcher(backgroundContext: backgroundContext,
85 | currentAuthor: AppActor.app1.rawValue,
86 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue])
87 |
88 | let cleaner = Cleaner(backgroundContext: backgroundContext,
89 | authors: [AppActor.app1.rawValue, AppActor.app2.rawValue])
90 |
91 | // when insert by batch
92 | viewContext.performAndWait {
93 | let event = Event(context: viewContext)
94 | event.timestamp = Date()
95 | viewContext.saveIfChanged()
96 | }
97 |
98 | try batchContext.performAndWait {
99 | var count = 0
100 |
101 | let batchInsert = NSBatchInsertRequest(entity: Event.entity()) { (dictionary: NSMutableDictionary) in
102 | dictionary["timestamp"] = Date()
103 | count += 1
104 | return count == 10
105 | }
106 | try batchContext.execute(batchInsert)
107 | }
108 |
109 | // then
110 | let transactionsBeforeClean = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2))
111 | XCTAssertEqual(transactionsBeforeClean.count, 1)
112 | XCTAssertEqual(transactionsBeforeClean.first?.changes?.count, 9)
113 |
114 | try cleaner.cleanTransaction(before: Date())
115 |
116 | let transactionsAfterClean = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2))
117 | XCTAssertEqual(transactionsAfterClean.count, 0)
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/Tests/PersistentHistoryTrackingKitTests/FetcherTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FetcherTests.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/11
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | @testable import PersistentHistoryTrackingKit
15 | import XCTest
16 |
17 | class FetcherTest: XCTestCase {
18 | let storeURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
19 | .first?
20 | .appendingPathComponent("PersistentHistoryKitFetcherTest.sqlite") ?? URL(fileURLWithPath: "")
21 |
22 | override func tearDown() async throws {
23 | try? FileManager.default.removeItem(at: storeURL)
24 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal"))
25 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm"))
26 | }
27 |
28 | func testFetcherAuthorsIncludingCloudKit() async throws {
29 | // given
30 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
31 | let app1backgroundContext = container1.newBackgroundContext()
32 |
33 | // when
34 | let fetcher1 = Fetcher(
35 | backgroundContext: app1backgroundContext,
36 | currentAuthor: AppActor.app1.rawValue,
37 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue]
38 | )
39 | let fetcher2 = Fetcher(
40 | backgroundContext:
41 | app1backgroundContext, currentAuthor: AppActor.app1.rawValue,
42 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue],
43 | includingCloudKitMirroring: true
44 | )
45 |
46 | // then
47 | XCTAssertEqual(fetcher1.allAuthors.count, 2)
48 | XCTAssertEqual(fetcher2.allAuthors.count, 3)
49 | XCTAssertTrue(fetcher2.allAuthors.contains("NSCloudKitMirroringDelegate.import"))
50 | }
51 |
52 | /// 使用两个协调器,模拟在app group的情况下,从不同的app或app extension中操作数据库。
53 | func testFetcherInAppGroup() async throws {
54 | // given
55 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
56 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
57 | let app1backgroundContext = container1.newBackgroundContext()
58 | let fetcher = Fetcher(backgroundContext: app1backgroundContext,
59 | currentAuthor: AppActor.app1.rawValue,
60 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue])
61 |
62 | let app1viewContext = container1.viewContext
63 | app1viewContext.transactionAuthor = AppActor.app1.rawValue
64 |
65 | let app2viewContext = container2.viewContext
66 | app2viewContext.transactionAuthor = AppActor.app2.rawValue
67 |
68 | // when
69 | app1viewContext.performAndWait {
70 | let event = Event(context: app1viewContext)
71 | event.timestamp = Date()
72 | app1viewContext.saveIfChanged()
73 | }
74 |
75 | app2viewContext.performAndWait {
76 | let event = Event(context: app2viewContext)
77 | event.timestamp = Date()
78 | app2viewContext.saveIfChanged()
79 | }
80 |
81 | // then
82 | let transactions = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2))
83 | XCTAssertEqual(transactions.count, 1)
84 |
85 | let request = NSFetchRequest(entityName: "Event")
86 | let eventCounts = try app1viewContext.count(for: request)
87 | XCTAssertEqual(eventCounts, 2)
88 | }
89 |
90 | func testFetcherInBatchOperation() async throws {
91 | guard #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) else {
92 | return
93 | }
94 | // given
95 | let container = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
96 | let viewContext = container.viewContext
97 | let batchContext = container.newBackgroundContext()
98 | let backgroundContext = container.newBackgroundContext()
99 |
100 | viewContext.transactionAuthor = AppActor.app1.rawValue
101 | batchContext.transactionAuthor = AppActor.app2.rawValue // 批量添加使用单独的author
102 |
103 | let fetcher = Fetcher(backgroundContext: backgroundContext,
104 | currentAuthor: AppActor.app1.rawValue,
105 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue])
106 |
107 | // when insert by batch
108 | viewContext.performAndWait {
109 | let event = Event(context: viewContext)
110 | event.timestamp = Date()
111 | viewContext.saveIfChanged()
112 | }
113 |
114 | try batchContext.performAndWait {
115 | var count = 0
116 |
117 | let batchInsert = NSBatchInsertRequest(entity: Event.entity()) { (dictionary: NSMutableDictionary) in
118 | dictionary["timestamp"] = Date()
119 | count += 1
120 | return count == 10
121 | }
122 | try batchContext.execute(batchInsert)
123 | }
124 |
125 | // then
126 | let transactions = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2))
127 | XCTAssertEqual(transactions.count, 1)
128 | XCTAssertEqual(transactions.first?.changes?.count, 9)
129 |
130 | let request = NSFetchRequest(entityName: "Event")
131 | let eventCounts = try viewContext.count(for: request)
132 | XCTAssertEqual(eventCounts, 10)
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/Tests/PersistentHistoryTrackingKitTests/LoggerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoggerTests.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/10
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | @testable import PersistentHistoryTrackingKit
14 | import XCTest
15 |
16 | class LoggerTests: XCTestCase {
17 | func testLogger() throws {
18 | // given
19 | let logger = LoggerSpy()
20 | // when
21 | logger.log(type: .info, message: "hello")
22 | // then
23 | XCTAssertEqual(LoggerSpy.message, "hello")
24 | XCTAssertEqual(LoggerSpy.type, .info)
25 | }
26 | }
27 |
28 | struct LoggerSpy: PersistentHistoryTrackingKitLoggerProtocol {
29 | static var type:PersistentHistoryTrackingKitLogType?
30 | static var message:String?
31 | func log(type: PersistentHistoryTrackingKitLogType, message: String) {
32 | Self.type = type
33 | Self.message = message
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Tests/PersistentHistoryTrackingKitTests/MergerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MergerTests.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/12
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | @testable import PersistentHistoryTrackingKit
15 | import XCTest
16 |
17 | class MergerTests: XCTestCase {
18 | let storeURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
19 | .first?
20 | .appendingPathComponent("PersistentHistoryTrackKitMergeTest.sqlite") ?? URL(fileURLWithPath: "")
21 |
22 | override func tearDown() async throws {
23 | await sleep(seconds: 2)
24 | try? FileManager.default.removeItem(at: storeURL)
25 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal"))
26 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm"))
27 | }
28 |
29 | func testMergerInAppGroup() throws {
30 | // given
31 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
32 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
33 | let app1backgroundContext = container1.newBackgroundContext()
34 | let fetcher = Fetcher(backgroundContext: app1backgroundContext,
35 | currentAuthor: AppActor.app1.rawValue,
36 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue])
37 | let merger = Merger()
38 |
39 | let app1viewContext = container1.viewContext
40 | app1viewContext.transactionAuthor = AppActor.app1.rawValue
41 |
42 | let app2viewContext = container2.viewContext
43 | app2viewContext.transactionAuthor = AppActor.app2.rawValue
44 |
45 | app2viewContext.performAndWait {
46 | let event = Event(context: app2viewContext)
47 | event.timestamp = Date()
48 | app2viewContext.saveIfChanged()
49 | }
50 | let transactions = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2))
51 |
52 | let userInfo = transactions.first?.objectIDNotification().userInfo ?? [:]
53 | guard let objectIDs = userInfo["inserted_objectIDs"] as? NSSet,
54 | let objectID = objectIDs.allObjects.first as? NSManagedObjectID
55 | else {
56 | fatalError()
57 | }
58 | app1viewContext.retainsRegisteredObjects = true // 为检查保持托管对象不清除
59 | app1backgroundContext.retainsRegisteredObjects = true
60 |
61 | // when
62 | merger(merge: transactions, into: [app1viewContext, app1backgroundContext])
63 |
64 | // then
65 | app1viewContext.performAndWait {
66 | XCTAssertNotNil(app1viewContext.registeredObject(for: objectID))
67 | }
68 | app1backgroundContext.performAndWait {
69 | XCTAssertNotNil(app1backgroundContext.registeredObject(for: objectID))
70 | }
71 | }
72 |
73 | func testMergerInBatchOperation() async throws {
74 | guard #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) else {
75 | return
76 | }
77 | // given
78 | let container = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
79 | let viewContext = container.viewContext
80 | let batchContext = container.newBackgroundContext()
81 | let backgroundContext = container.newBackgroundContext()
82 |
83 | viewContext.transactionAuthor = AppActor.app1.rawValue
84 | batchContext.transactionAuthor = AppActor.app2.rawValue // 批量添加使用单独的author
85 |
86 | let fetcher = Fetcher(backgroundContext: backgroundContext,
87 | currentAuthor: AppActor.app1.rawValue,
88 | allAuthors: [AppActor.app1.rawValue, AppActor.app2.rawValue])
89 |
90 | let merger = Merger()
91 | // insert by batch
92 | try batchContext.performAndWait {
93 | var count = 0
94 |
95 | let batchInsert = NSBatchInsertRequest(entity: Event.entity()) { (dictionary: NSMutableDictionary) in
96 | dictionary["timestamp"] = Date()
97 | count += 1
98 | return count == 10
99 | }
100 | try batchContext.execute(batchInsert)
101 | }
102 |
103 | let transactions = try fetcher.fetchTransactions(from: Date().addingTimeInterval(-2))
104 |
105 | let userInfo = transactions.first?.objectIDNotification().userInfo ?? [:]
106 | guard let objectIDs = userInfo["inserted_objectIDs"] as? NSSet,
107 | let objectID = objectIDs.allObjects.first as? NSManagedObjectID
108 | else {
109 | fatalError()
110 | }
111 | viewContext.retainsRegisteredObjects = true
112 | viewContext.performAndWait {
113 | XCTAssertNil(viewContext.registeredObject(for: objectID))
114 | }
115 | // when
116 | merger(merge: transactions, into: [viewContext])
117 |
118 | // then
119 | viewContext.performAndWait {
120 | XCTAssertNotNil(viewContext.registeredObject(for: objectID))
121 | }
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/Tests/PersistentHistoryTrackingKitTests/PersistentHistoryTrackingKitTests.swift:
--------------------------------------------------------------------------------
1 | import CoreData
2 | import PersistentHistoryTrackingKit
3 | import XCTest
4 |
5 | @MainActor
6 | final class PersistentHistoryTrackingKitTests: XCTestCase {
7 | let storeURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
8 | .first?
9 | .appendingPathComponent("PersistentHistoryKitTestDB.sqlite") ?? URL(fileURLWithPath: "")
10 | let uniqueString = "PersistentHistoryTrackingKit.lastToken.tests."
11 | let userDefaults = UserDefaults.standard
12 |
13 | override func setUpWithError() throws {
14 | // 清除 UserDefaults 环境
15 | for author in AppActor.allCases {
16 | userDefaults.removeObject(forKey: uniqueString + author.rawValue)
17 | }
18 | }
19 |
20 | override func tearDown() async throws {
21 | await sleep(seconds: 3)
22 | try? FileManager.default.removeItem(at: storeURL)
23 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal"))
24 | try? FileManager.default.removeItem(at: storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm"))
25 | }
26 |
27 | func testPersistentHistoryKitInAppGroup() async throws {
28 | // given
29 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
30 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
31 | container1.viewContext.transactionAuthor = AppActor.app1.rawValue
32 | container2.viewContext.transactionAuthor = AppActor.app2.rawValue
33 | let authors = [AppActor.app1.rawValue, AppActor.app2.rawValue]
34 | let kit = PersistentHistoryTrackingKit(
35 | container: container1,
36 | currentAuthor: AppActor.app1.rawValue,
37 | allAuthors: authors,
38 | userDefaults: userDefaults,
39 | cleanStrategy: .byNotification(times: 1),
40 | uniqueString: uniqueString,
41 | logLevel: 3,
42 | autoStart: false
43 | )
44 |
45 | kit.start()
46 |
47 | let viewContext1 = container1.viewContext
48 | let viewContext2 = container2.viewContext
49 | viewContext1.retainsRegisteredObjects = true
50 | // when
51 |
52 | let objectID: NSManagedObjectID = viewContext2.performAndWait {
53 | let event = Event(context: viewContext2)
54 | event.timestamp = Date()
55 | viewContext2.saveIfChanged()
56 | return event.objectID
57 | }
58 |
59 | // then
60 | await sleep(seconds: 2)
61 |
62 | await viewContext1.perform {
63 | XCTAssertNotNil(viewContext1.registeredObject(for: objectID))
64 | }
65 | let lastTimestamp = userDefaults.value(forKey: uniqueString + AppActor.app1.rawValue) as? Date
66 | XCTAssertNotNil(lastTimestamp)
67 |
68 | kit.stop()
69 | await sleep(seconds: 2)
70 | }
71 |
72 | // swiftlint:disable:next function_body_length
73 | func testKitInBatchInsert() async throws {
74 | guard #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) else {
75 | return
76 | }
77 | // given
78 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
79 | let viewContext = container1.viewContext
80 | viewContext.transactionAuthor = AppActor.app1.rawValue
81 | viewContext.retainsRegisteredObjects = true
82 | let batchContext = container1.newBackgroundContext()
83 | batchContext.transactionAuthor = AppActor.app2.rawValue
84 | let authors = [AppActor.app1.rawValue, AppActor.app2.rawValue]
85 | let anotherContext = container1.newBackgroundContext()
86 | anotherContext.retainsRegisteredObjects = true
87 | let kit = PersistentHistoryTrackingKit(
88 | viewContext: container1.viewContext,
89 | contexts: [viewContext, anotherContext], // test merge to multi context
90 | currentAuthor: AppActor.app1.rawValue,
91 | allAuthors: authors,
92 | batchAuthors: [AppActor.app2.rawValue],
93 | userDefaults: userDefaults,
94 | cleanStrategy: .byNotification(times: 1),
95 | uniqueString: uniqueString,
96 | logLevel: 3
97 | )
98 | try batchContext.performAndWait {
99 | var count = 0
100 |
101 | let batchInsert = NSBatchInsertRequest(entity: Event.entity()) { (dictionary: NSMutableDictionary) in
102 | dictionary["timestamp"] = Date()
103 | count += 1
104 | return count == 10
105 | }
106 | try batchContext.execute(batchInsert)
107 | }
108 |
109 | // when
110 | let objectID: NSManagedObjectID = batchContext.performAndWait {
111 | let request = NSFetchRequest(entityName: "Event")
112 | request.sortDescriptors = [NSSortDescriptor(keyPath: \Event.timestamp, ascending: false)]
113 | guard let results = try? batchContext.fetch(request),
114 | let object = results.first else { fatalError() }
115 | return object.objectID
116 | }
117 | await sleep(seconds: 2)
118 | // then
119 | viewContext.performAndWait {
120 | XCTAssertNotNil(viewContext.registeredObject(for: objectID))
121 | }
122 | anotherContext.performAndWait {
123 | XCTAssertNotNil(anotherContext.registeredObject(for: objectID))
124 | }
125 | kit.stop()
126 | await sleep(seconds: 2)
127 | }
128 |
129 | func testManualCleaner() async throws {
130 | // given
131 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
132 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
133 | container1.viewContext.transactionAuthor = AppActor.app1.rawValue
134 | container2.viewContext.transactionAuthor = AppActor.app2.rawValue
135 | let authors = [AppActor.app1.rawValue, AppActor.app2.rawValue]
136 | let kit = PersistentHistoryTrackingKit(
137 | container: container1,
138 | currentAuthor: AppActor.app1.rawValue,
139 | allAuthors: authors,
140 | userDefaults: userDefaults,
141 | cleanStrategy: .none,
142 | uniqueString: uniqueString,
143 | logLevel: 2,
144 | autoStart: false
145 | )
146 |
147 | let cleaner = kit.cleanerBuilder()
148 |
149 | kit.start()
150 |
151 | let viewContext1 = container1.viewContext
152 | let viewContext2 = container2.viewContext
153 | viewContext1.retainsRegisteredObjects = true
154 |
155 | // when
156 |
157 | let objectID: NSManagedObjectID = viewContext2.performAndWait {
158 | let event = Event(context: viewContext2)
159 | event.timestamp = Date()
160 | viewContext2.saveIfChanged()
161 | return event.objectID
162 | }
163 |
164 | // then
165 | await sleep(seconds: 2)
166 |
167 | cleaner() // 手动清除
168 |
169 | viewContext1.performAndWait {
170 | XCTAssertNotNil(viewContext1.registeredObject(for: objectID))
171 | }
172 | let lastTimestamp = userDefaults.value(forKey: uniqueString + AppActor.app1.rawValue) as? Date
173 | XCTAssertNotNil(lastTimestamp)
174 |
175 | kit.stop()
176 | await sleep(seconds: 2)
177 | }
178 |
179 | /// 测试两个app都执行了Kit后,transaction 是否有被清除
180 | func testTwoAppWithKit() async throws {
181 | // given
182 | let container1 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
183 | let container2 = CoreDataHelper.createNSPersistentContainer(storeURL: storeURL)
184 | let viewContext1 = container1.viewContext
185 | viewContext1.transactionAuthor = AppActor.app1.rawValue
186 | let viewContext2 = container2.viewContext
187 | viewContext2.transactionAuthor = AppActor.app2.rawValue
188 | viewContext1.retainsRegisteredObjects = true
189 | viewContext2.retainsRegisteredObjects = true
190 | let authors = [AppActor.app1.rawValue, AppActor.app2.rawValue, AppActor.app3.rawValue]
191 |
192 | let app1kit = PersistentHistoryTrackingKit(
193 | container: container1,
194 | contexts: [viewContext1],
195 | currentAuthor: AppActor.app1.rawValue,
196 | allAuthors: authors,
197 | userDefaults: userDefaults,
198 | uniqueString: uniqueString,
199 | logLevel: 2
200 | )
201 |
202 | let app2kit = PersistentHistoryTrackingKit(
203 | container: container1,
204 | contexts: [viewContext2],
205 | currentAuthor: AppActor.app2.rawValue,
206 | allAuthors: authors,
207 | userDefaults: userDefaults,
208 | uniqueString: uniqueString,
209 | logLevel: 2
210 | )
211 |
212 | let backgroundContext = container1.newBackgroundContext()
213 | backgroundContext.transactionAuthor = AppActor.app3.rawValue
214 |
215 | // when
216 | let objectID: NSManagedObjectID = backgroundContext.performAndWait {
217 | let event = Event(context: backgroundContext)
218 | event.timestamp = Date()
219 | backgroundContext.saveIfChanged()
220 | return event.objectID
221 | }
222 |
223 | await sleep(seconds: 2)
224 |
225 | // then
226 | viewContext1.performAndWait {
227 | XCTAssertNotNil(viewContext1.registeredObject(for: objectID))
228 | }
229 |
230 | viewContext2.performAndWait {
231 | XCTAssertNotNil(viewContext2.registeredObject(for: objectID))
232 | }
233 |
234 | app1kit.stop()
235 | app2kit.stop()
236 | }
237 | }
238 |
--------------------------------------------------------------------------------
/Tests/PersistentHistoryTrackingKitTests/TestsHelper/CoreDataStackHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/11
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | import Foundation
15 |
16 | class CoreDataHelper {
17 | static func createNSPersistentContainer(
18 | storeURL: URL? = URL(fileURLWithPath: "/dev/null"),
19 | enablePersistentHistoryTrack: Bool = true
20 | ) -> NSPersistentContainer {
21 | let container = NSPersistentContainer(name: "Test Model", managedObjectModel: Self.model)
22 | guard let desc = container.persistentStoreDescriptions.first else {
23 | fatalError()
24 | }
25 | desc.url = storeURL
26 | if enablePersistentHistoryTrack {
27 | desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
28 | desc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
29 | }
30 | container.persistentStoreDescriptions = [desc]
31 | container.loadPersistentStores(completionHandler: { _, error in
32 | if let error = error {
33 | fatalError("create container error : \(error.localizedDescription)")
34 | }
35 | })
36 | return container
37 | }
38 |
39 | /// 创建一个NSManagedObjectModel Entity: Event property: timestamp
40 | static func createTestNSManagedObjectModelModel() -> NSManagedObjectModel {
41 | let eventEntity = NSEntityDescription()
42 | eventEntity.name = "Event"
43 | eventEntity.managedObjectClassName = "Event"
44 |
45 | let timestampAttribute = NSAttributeDescription()
46 |
47 | if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) {
48 | timestampAttribute.type = .date
49 | } else {
50 | timestampAttribute.attributeType = .dateAttributeType
51 | }
52 | timestampAttribute.name = "timestamp"
53 | eventEntity.properties.append(timestampAttribute)
54 |
55 | let model = NSManagedObjectModel()
56 | model.entities = [eventEntity]
57 | return model
58 | }
59 |
60 | static var model = createTestNSManagedObjectModelModel()
61 | }
62 |
63 | @objc(Event)
64 | class Event: NSManagedObject {
65 | @NSManaged var timestamp: Date?
66 | }
67 |
--------------------------------------------------------------------------------
/Tests/PersistentHistoryTrackingKitTests/TestsHelper/Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Extension.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/11
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | import CoreData
14 | import Foundation
15 |
16 | extension NSManagedObjectContext {
17 | func saveIfChanged() {
18 | guard self.hasChanges else { return }
19 | do {
20 | try self.save()
21 | } catch {
22 | fatalError("Context save error: \(error.localizedDescription)")
23 | }
24 | }
25 | }
26 |
27 | extension NSManagedObjectContext {
28 | @discardableResult
29 | func performAndWait(_ block: () throws -> T) throws -> T {
30 | var result: Result?
31 | performAndWait {
32 | result = Result { try block() }
33 | }
34 | return try result!.get()
35 | }
36 |
37 | @discardableResult
38 | func performAndWait(_ block: () -> T) -> T {
39 | var result: T?
40 | performAndWait {
41 | result = block()
42 | }
43 | return result!
44 | }
45 | }
46 |
47 | func sleep(seconds: Double) async {
48 | try? await Task.sleep(seconds: seconds)
49 | }
50 |
--------------------------------------------------------------------------------
/Tests/PersistentHistoryTrackingKitTests/TimestampManagerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimestampManagerTests.swift
3 | //
4 | //
5 | // Created by Yang Xu on 2022/2/10
6 | // Copyright © 2022 Yang Xu. All rights reserved.
7 | //
8 | // Follow me on Twitter: @fatbobman
9 | // My Blog: https://www.fatbobman.com
10 | // 微信公共号: 肘子的Swift记事本
11 | //
12 |
13 | @testable import PersistentHistoryTrackingKit
14 | import XCTest
15 |
16 | class TimestampManagerTests: XCTestCase {
17 | let uniqueString = "PersistentHistoryTrackingKit.lastToken.Tests."
18 | let userDefaults = UserDefaults.standard
19 |
20 | override func setUpWithError() throws {
21 | // 清除 UserDefaults 环境
22 | for author in AppActor.allCases {
23 | userDefaults.removeObject(forKey: uniqueString + author.rawValue)
24 | }
25 | }
26 |
27 | func testSetSingleAuthorTimestamp() {
28 | // given
29 | let author = AppActor.app1.rawValue
30 | let manager = TransactionTimestampManager(userDefaults: userDefaults, uniqueString: uniqueString)
31 | let key = uniqueString + author
32 |
33 | // when
34 | let date = Date()
35 | manager.updateLastHistoryTransactionTimestamp(for: author, to: date)
36 |
37 | // then
38 | XCTAssertEqual(date, userDefaults.value(forKey: key) as? Date)
39 | }
40 |
41 | func testNoAuthorUpdateTimestamp() {
42 | // given
43 | let max:TimeInterval = 100
44 | let manager = TransactionTimestampManager(userDefaults: userDefaults, maximumDuration: max, uniqueString: uniqueString)
45 | let authors = AppActor.allCases.map { $0.rawValue }
46 |
47 | // when
48 | let lastTimestamp = manager.getLastCommonTransactionTimestamp(in: authors)
49 |
50 | // then
51 | XCTAssertNotNil(lastTimestamp)
52 | }
53 |
54 | func testAllAuthorsHaveUpdatedTimestamp() {
55 | // given
56 | let manager = TransactionTimestampManager(userDefaults: userDefaults, uniqueString: uniqueString)
57 |
58 | let date1 = Date().addingTimeInterval(-1000)
59 | let date2 = Date().addingTimeInterval(-2000)
60 | let date3 = Date().addingTimeInterval(-3000)
61 |
62 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app1.rawValue, to: date1)
63 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app2.rawValue, to: date2)
64 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app3.rawValue, to: date3)
65 |
66 | let authors = AppActor.allCases.map { $0.rawValue }
67 |
68 | // when
69 | let lastTimestamp = manager.getLastCommonTransactionTimestamp(in: authors)
70 |
71 | // then
72 | XCTAssertEqual(lastTimestamp, date3)
73 | }
74 |
75 | // 仅部分author设置了时间戳,尚未触及阈值日期
76 | func testPartOfAuthorsHaveUpdatedTimestampAndThresholdNotYetTouched() {
77 | // given
78 | let manager = TransactionTimestampManager(userDefaults: userDefaults, uniqueString: uniqueString)
79 |
80 | let date1 = Date().addingTimeInterval(-1000)
81 | let date2 = Date().addingTimeInterval(-2000)
82 |
83 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app1.rawValue, to: date1)
84 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app2.rawValue, to: date2)
85 |
86 | let authors = AppActor.allCases.map { $0.rawValue }
87 |
88 | // when
89 | let lastTimestampe = manager.getLastCommonTransactionTimestamp(in: authors)
90 |
91 | // then
92 | XCTAssertNotNil(lastTimestampe)
93 | }
94 |
95 | // 部分author设置了时间戳,已触及阈值日期
96 | func testPartOfAuthorsHaveUpdatedTimestampAndTouchedThreshold() {
97 | // given
98 | let maxDuration = 3000.0
99 | let manager = TransactionTimestampManager(
100 | userDefaults: userDefaults,
101 | maximumDuration: maxDuration,
102 | uniqueString: uniqueString
103 | )
104 |
105 | let date1 = Date().addingTimeInterval(-(maxDuration + 1000))
106 | let date2 = Date().addingTimeInterval(-(maxDuration + 2000))
107 |
108 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app1.rawValue, to: date1)
109 | manager.updateLastHistoryTransactionTimestamp(for: AppActor.app2.rawValue, to: date2)
110 |
111 | let authors = AppActor.allCases.map { $0.rawValue }
112 |
113 | // when
114 | let lastTimestamp = manager.getLastCommonTransactionTimestamp(in: authors)
115 |
116 | // then
117 | XCTAssertNotNil(lastTimestamp)
118 | if let lastTimestamp = lastTimestamp {
119 | XCTAssertLessThan(lastTimestamp, date1)
120 | }
121 | }
122 |
123 | // 测试当batchAuthors有内容时,是否可以获取正确的时间
124 | func testGetLastCommonTimestampWhenBatchAuthorsIsNotEmpty() {
125 | // given
126 | let manager = TransactionTimestampManager(userDefaults: userDefaults, uniqueString: uniqueString)
127 |
128 | let authors = ["app1", "app1Batch"]
129 | let batchAuthors = ["app1Batch"]
130 | let currentAuthor = "app1"
131 |
132 | let updateDate = Date()
133 |
134 | // when
135 | manager.updateLastHistoryTransactionTimestamp(for: currentAuthor, to: updateDate)
136 | let lastDate1 = manager.getLastCommonTransactionTimestamp(in: authors)
137 |
138 | // then
139 | XCTAssertNotNil(lastDate1)
140 |
141 | // when
142 | let lastDate2 = manager.getLastCommonTransactionTimestamp(in: authors, exclude: batchAuthors)
143 |
144 | XCTAssertEqual(lastDate2, updateDate)
145 | }
146 | }
147 |
148 | enum AppActor: String, CaseIterable {
149 | case app1, app2, app3
150 | }
151 |
--------------------------------------------------------------------------------