From 27a403c99bfd2567470346a89e9d0d5b5a0692f5 Mon Sep 17 00:00:00 2001 From: Laurent Date: Thu, 3 Apr 2025 11:19:26 +0200 Subject: [PATCH] Make collection loading asynchronous + manage state when collection are not loaded + separate StoredCollection from synced collection --- LeStorage.xcodeproj/project.pbxproj | 24 ++- LeStorage/ApiCallCollection.swift | 97 ++++++---- ...dCollection.swift => BaseCollection.swift} | 167 +++++++++++++----- LeStorage/Codables/PendingOperation.swift | 34 ++++ LeStorage/PendingOperationManager.swift | 62 +++++++ LeStorage/Store.swift | 55 +++--- LeStorage/StoreCenter.swift | 60 +++---- LeStorage/StoredSingleton.swift | 2 +- ...tion+Sync.swift => SyncedCollection.swift} | 87 ++++----- LeStorageTests/ApiCallTests.swift | 39 +++- LeStorageTests/CollectionsTests.swift | 49 ++++- LeStorageTests/IdentifiableTests.swift | 49 ++++- LeStorageTests/StoredCollectionTests.swift | 122 +++++++++---- 13 files changed, 608 insertions(+), 239 deletions(-) rename LeStorage/{StoredCollection.swift => BaseCollection.swift} (73%) create mode 100644 LeStorage/Codables/PendingOperation.swift create mode 100644 LeStorage/PendingOperationManager.swift rename LeStorage/{StoredCollection+Sync.swift => SyncedCollection.swift} (79%) diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index 675091c..e17f856 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400D7222CC2AF560092237C /* GetSyncData.swift */; }; + C40EC3E52D9BDFA3007372D7 /* PendingOperationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */; }; C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */ = {isa = PBXBuildFile; fileRef = C425D4382B6D24E1002A7B48 /* LeStorage.docc */; }; C425D4452B6D24E1002A7B48 /* LeStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C425D4372B6D24E1002A7B48 /* LeStorage.h */; settings = {ATTRIBUTES = (Public, ); }; }; C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; }; @@ -16,10 +17,11 @@ C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; }; C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */; }; C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AAE22CD2466400D76CD2 /* Formatter.swift */; }; + C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */; }; C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; }; C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; }; C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; }; - C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; }; + C4A47D4F2B6D280200ADC637 /* BaseCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */; }; C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; }; C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D522B6D2C5F00ADC637 /* Logger.swift */; }; C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */; }; @@ -42,7 +44,7 @@ C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477962CB66EEA0077713D /* Date+Extensions.swift */; }; C4D4779D2CB923720077713D /* DataLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779C2CB923720077713D /* DataLog.swift */; }; C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779E2CB92FD80077713D /* SyncedStorable.swift */; }; - C4D477A12CB9586A0077713D /* StoredCollection+Sync.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */; }; + C4D477A12CB9586A0077713D /* SyncedCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477A02CB9586A0077713D /* SyncedCollection.swift */; }; C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE6992CEB84B300790446 /* WebSocketManager.swift */; }; C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE69B2CEB8E9500790446 /* URLManager.swift */; }; C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */; }; @@ -61,6 +63,7 @@ /* Begin PBXFileReference section */ C400D7222CC2AF560092237C /* GetSyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSyncData.swift; sourceTree = ""; }; + C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperationManager.swift; sourceTree = ""; }; C425D4342B6D24E1002A7B48 /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C425D4372B6D24E1002A7B48 /* LeStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LeStorage.h; sourceTree = ""; }; C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = ""; }; @@ -70,10 +73,11 @@ C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = ""; }; C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = ""; }; C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = ""; }; + C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.swift; sourceTree = ""; }; C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = ""; }; C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = ""; }; - C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = ""; }; + C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCollection.swift; sourceTree = ""; }; C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = ""; }; C4A47D522B6D2C5F00ADC637 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; @@ -96,7 +100,7 @@ C4D477962CB66EEA0077713D /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.swift; sourceTree = ""; }; C4D4779E2CB92FD80077713D /* SyncedStorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedStorable.swift; sourceTree = ""; }; - C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredCollection+Sync.swift"; sourceTree = ""; }; + C4D477A02CB9586A0077713D /* SyncedCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedCollection.swift; sourceTree = ""; }; C4FAE6992CEB84B300790446 /* WebSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketManager.swift; sourceTree = ""; }; C4FAE69B2CEB8E9500790446 /* URLManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLManager.swift; sourceTree = ""; }; C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = ""; }; @@ -155,13 +159,14 @@ C4A47D6C2B71364600ADC637 /* ModelObject.swift */, C488C87F2CCBDC210082001F /* NetworkMonitor.swift */, C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */, + C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */, C4AC9CE92CF754CC00CC13DF /* Relationship.swift */, C4A47D602B6D3C1300ADC637 /* Services.swift */, C425D4572B6D2519002A7B48 /* Store.swift */, C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */, C4A47D642B6E92FE00ADC637 /* Storable.swift */, - C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */, - C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */, + C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */, + C4D477A02CB9586A0077713D /* SyncedCollection.swift */, C456EFE12BE52379007388E2 /* StoredSingleton.swift */, C4D4779E2CB92FD80077713D /* SyncedStorable.swift */, C4FAE6992CEB84B300790446 /* WebSocketManager.swift */, @@ -210,6 +215,7 @@ C4A47D9A2B7CFFC500ADC637 /* Settings.swift */, C400D7222CC2AF560092237C /* GetSyncData.swift */, C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */, + C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */, ); path = Codables; sourceTree = ""; @@ -332,8 +338,9 @@ C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */, C4A47D842B7B97F000ADC637 /* KeychainStore.swift in Sources */, C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */, - C4D477A12CB9586A0077713D /* StoredCollection+Sync.swift in Sources */, + C4D477A12CB9586A0077713D /* SyncedCollection.swift in Sources */, C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */, + C40EC3E52D9BDFA3007372D7 /* PendingOperationManager.swift in Sources */, C4AC9CE52CEFB12100CC13DF /* DataAccess.swift in Sources */, C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */, C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */, @@ -351,7 +358,7 @@ C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */, C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */, C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */, - C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */, + C4A47D4F2B6D280200ADC637 /* BaseCollection.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */, C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */, @@ -362,6 +369,7 @@ C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */, C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */, C425D4582B6D2519002A7B48 /* Store.swift in Sources */, + C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */, C4D4779D2CB923720077713D /* DataLog.swift in Sources */, C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */, C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */, diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index cc74281..e7f4f8a 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -141,6 +141,7 @@ actor ApiCallCollection: SomeCallCollection { /// Removes all objects in memory and deletes the JSON file func reset() { self._isExecutingCalls = false + self._schedulingTask?.cancel() self.items.removeAll() do { @@ -255,7 +256,7 @@ actor ApiCallCollection: SomeCallCollection { /// The method makes some clean up when necessary: /// - When deleting, we delete other calls as they are unecessary /// - When updating, we delete other PUT as we don't want them to be executed in random orders - func callForInstance(_ instance: T, method: HTTPMethod, transactionId: String? = nil) throws -> ApiCall { + func callForInstance(_ instance: T, method: HTTPMethod, transactionId: String? = nil) -> ApiCall { // cleanup if necessary switch method { @@ -269,24 +270,31 @@ actor ApiCallCollection: SomeCallCollection { break } - let call: ApiCall = try self._createCall(method, instance: instance, transactionId: transactionId) + let call: ApiCall = self._createCall(method, instance: instance, transactionId: transactionId) self._prepareCall(apiCall: call) return call } + /// deletes an array of ApiCall by id fileprivate func _deleteCalls(_ calls: [ApiCall]) { for call in calls { self.deleteById(call.id) } } - fileprivate func _createGetCall() throws -> ApiCall { - return try self._createCall(.get, instance: nil) + /// we want to avoid sending the same GET twice + fileprivate func _createGetCallIfNonExistent(_ parameters: [String : String]?) -> ApiCall? { + if let _ = self.items.first(where: { $0.method == .get && $0.urlParameters == parameters }) { + return nil + } + let call = self._createCall(.get, instance: nil) + call.urlParameters = parameters + return call } /// Creates an API call for the Storable [instance] and an HTTP [method] - fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil) throws -> ApiCall { + fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil) -> ApiCall { if let instance { return ApiCall(method: method, data: instance, transactionId: transactionId) } else { @@ -301,61 +309,65 @@ actor ApiCallCollection: SomeCallCollection { self.addOrUpdate(apiCall) } + /// Sends a GET request with an URLParameterConvertible [instance] + func sendGetRequest(instance: URLParameterConvertible) async throws { + let parameters = instance.queryParameters() + try await self._sendGetRequest(parameters: parameters) + } + + /// Sends a GET request with an optional [storeId] + func sendGetRequest(storeId: String?) async throws { + var parameters: [String : String]? = nil + if let storeId { + parameters = [Services.storeIdURLParameter : storeId] + } + try await self._sendGetRequest(parameters: parameters) + } + /// Sends an insert api call for the provided [instance] - func sendGetRequest(instance: T? = nil, storeId: String? = nil) async throws { - do { - let apiCall = ApiCall(method: .get, data: nil) - if let parameteredInstance = instance as? URLParameterConvertible { - apiCall.urlParameters = parameteredInstance.queryParameters() - } - if let storeId { - apiCall.urlParameters = [Services.storeIdURLParameter : storeId] + fileprivate func _sendGetRequest(parameters: [String : String]?) async throws { + + if let getCall = self._createGetCallIfNonExistent(parameters) { + do { + try await self._prepareAndSendGetCall(getCall) + } catch { + self.rescheduleApiCallsIfNecessary() + Logger.error(error) } - try await self._prepareAndSendGetCall(apiCall) - } catch { - self.rescheduleApiCallsIfNecessary() - Logger.error(error) + } else { + self.rescheduleImmediately() } } - func executeBatch(_ batch: OperationBatch) async throws { + /// Creates and execute the ApiCalls corresponding to the [batch] + func executeBatch(_ batch: OperationBatch) { var apiCalls: [ApiCall] = [] let transactionId = Store.randomId() for insert in batch.inserts { - let call = try self.callForInstance(insert, method: .post, transactionId: transactionId) + let call = self.callForInstance(insert, method: .post, transactionId: transactionId) apiCalls.append(call) } for update in batch.updates { - let call = try self.callForInstance(update, method: .put, transactionId: transactionId) + let call = self.callForInstance(update, method: .put, transactionId: transactionId) apiCalls.append(call) } for delete in batch.deletes { - let call = try self.callForInstance(delete, method: .delete, transactionId: transactionId) + let call = self.callForInstance(delete, method: .delete, transactionId: transactionId) apiCalls.append(call) } self.rescheduleImmediately() - -// return try await self._executeApiCalls(apiCalls) } + /// Prepares and executes a GET call fileprivate func _prepareAndSendGetCall(_ apiCall: ApiCall) async throws { self._prepareCall(apiCall: apiCall) try await self._executeGetCall(apiCall: apiCall) } - /// Executes an API call - /// For POST requests, potentially copies additional data coming from the server during the insert -// fileprivate func _executeGetCall(_ apiCall: ApiCall) async throws -> V { -// return try await StoreCenter.main.executeGet(apiCall: apiCall) -// } - /// Executes an API call /// For POST requests, potentially copies additional data coming from the server during the insert fileprivate func _executeApiCalls(_ apiCalls: [ApiCall]) async throws -> [OperationResult] { -// for call in apiCalls { -// Logger.log("execute call = \(call.id)") -// } let results = try await StoreCenter.main.execute(apiCalls: apiCalls) for result in results { switch result.status { @@ -383,6 +395,7 @@ actor ApiCallCollection: SomeCallCollection { return self.items.isNotEmpty } + /// returns the list of API calls in the collection func apiCalls() -> [ApiCall] { return self.items } @@ -390,4 +403,24 @@ actor ApiCallCollection: SomeCallCollection { func type() async -> any Storable.Type { return T.self } func resourceName() async -> String { return T.resourceName() } + // MARK: - Testing + + func sendInsertion(_ instance: T) async throws { + let batch = OperationBatch() + batch.addInsert(instance) + self.executeBatch(batch) + } + + func sendUpdate(_ instance: T) async throws { + let batch = OperationBatch() + batch.addUpdate(instance) + self.executeBatch(batch) + } + + func sendDeletion(_ instance: T) async throws { + let batch = OperationBatch() + batch.addDelete(instance) + self.executeBatch(batch) + } + } diff --git a/LeStorage/StoredCollection.swift b/LeStorage/BaseCollection.swift similarity index 73% rename from LeStorage/StoredCollection.swift rename to LeStorage/BaseCollection.swift index dbe9a7f..ce51dd3 100644 --- a/LeStorage/StoredCollection.swift +++ b/LeStorage/BaseCollection.swift @@ -1,5 +1,5 @@ // -// StoredCollection.swift +// BaseCollection.swift // LeStorage // // Created by Laurent Morvillier on 02/02/2024. @@ -7,22 +7,23 @@ import Foundation -protocol CollectionHolder { - associatedtype Item +public protocol CollectionHolder { + associatedtype Item: Storable var items: [Item] { get } func reset() } -protocol SomeCollection: CollectionHolder, Identifiable { +public protocol SomeCollection: CollectionHolder, Identifiable { + var resourceName: String { get } var hasLoaded: Bool { get } var inMemory: Bool { get } var type: any Storable.Type { get } - func allItems() -> [any Storable] func referenceCount(type: S.Type, id: String) -> Int + func findById(_ id: Item.ID) -> Item? } protocol SomeSyncedCollection: SomeCollection { @@ -30,11 +31,10 @@ protocol SomeSyncedCollection: SomeCollection { func loadCollectionsFromServerIfNoFile() async throws } -public class StoredCollection: RandomAccessCollection, SomeCollection, CollectionHolder -{ +public class BaseCollection: SomeCollection, CollectionHolder { /// Doesn't write the collection in a file - fileprivate(set) var inMemory: Bool = false + fileprivate(set) public var inMemory: Bool = false /// The list of stored items @Published public fileprivate(set) var items: [T] = [] @@ -45,6 +45,9 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Provides fast access for instances if the collection has been instanced with [indexed] = true fileprivate var _indexes: [T.ID: T]? = nil + /// A PendingOperationManager instance that manages operations while the collection is not loaded + fileprivate var _pendingOperationManager: PendingOperationManager? = nil + /// Indicates whether the collection has changed, thus requiring a write operation fileprivate var _hasChanged: Bool = false { didSet { @@ -63,6 +66,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Indicates if the collection has loaded locally, with or without a file fileprivate(set) public var hasLoaded: Bool = false + /// Sets a max number of items inside the collection fileprivate(set) var limit: Int? = nil init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) { @@ -73,21 +77,18 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self.store = store self.limit = limit - self.load() + Task(priority: .high) { + await self.load() + } + } - fileprivate init() { - // self.synchronized = false + init() { self.store = Store.main } - /// Returns a dummy StoredCollection instance - public static func placeholder() -> StoredCollection { - return StoredCollection() - } - /// Returns the name of the managed resource - var resourceName: String { + public var resourceName: String { return T.resourceName() } @@ -103,25 +104,27 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } /// Migrates if necessary and asynchronously decodes the json file - func load() { - - do { - if !self.inMemory { - try self.loadFromFile() + func load() async { + if !self.inMemory { + await self.loadFromFile() + } else { + await MainActor.run { + self.setAsLoaded() } - } catch { - Logger.error(error) } - } /// Starts the JSON file decoding synchronously or asynchronously - func loadFromFile() throws { - try self._decodeJSONFile() + func loadFromFile() async { + do { + try await self._decodeJSONFile() + } catch { + Logger.error(error) + } } /// Decodes the json file into the items array - fileprivate func _decodeJSONFile() throws { + fileprivate func _decodeJSONFile() async throws { let fileURL = try self.store.fileURL(type: T.self) @@ -130,17 +133,37 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti let decoded: [T] = try jsonString.decodeArray() ?? [] self._setItems(decoded) } - self.setAsLoaded() + await MainActor.run { + self.setAsLoaded() + } + } /// Sets the collection as loaded /// Send a CollectionDidLoad event + @MainActor func setAsLoaded() { self.hasLoaded = true - DispatchQueue.main.async { - NotificationCenter.default.post( - name: NSNotification.Name.CollectionDidLoad, object: self) + self._mergePendingOperations() + + NotificationCenter.default.post( + name: NSNotification.Name.CollectionDidLoad, object: self) + } + + fileprivate func _mergePendingOperations() { + + guard let manager = self._pendingOperationManager, manager.items.isNotEmpty else { return } + + Logger.log(">>> Merge pending: \(manager.items.count)") + for operation in manager.items { + switch operation.method { + case .addOrUpdate: + self.addOrUpdate(instance: operation.data) + case .delete: + self.delete(instance: operation.data) + } } + self._pendingOperationManager = nil } /// Sets a collection of items and indexes them @@ -201,7 +224,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } /// Deletes an item by its id - func deleteById(_ id: T.ID) { + public func deleteById(_ id: T.ID) { if let instance = self.findById(id) { self.delete(instance: instance) } @@ -261,16 +284,28 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } /// Adds an instance to the collection - func addItem(instance: T) { + @discardableResult func addItem(instance: T) -> Bool { + + if !self.hasLoaded { + self._addPendingOperation(method: .addOrUpdate, instance: instance) + return false + } + self._affectStoreIdIfNecessary(instance: instance) self.items.append(instance) instance.store = self.store self._indexes?[instance.id] = instance self._applyLimitIfPresent() + return true } /// Updates an instance to the collection by index - func updateItem(_ instance: T, index: Int) { + @discardableResult func updateItem(_ instance: T, index: Int) -> Bool { + + if !self.hasLoaded { + self._addPendingOperation(method: .addOrUpdate, instance: instance) + return false + } let item = self.items[index] if item !== instance { @@ -279,15 +314,30 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti instance.store = self.store self._indexes?[instance.id] = instance + return true } /// Deletes an instance from the collection - func deleteItem(_ instance: T) { + @discardableResult func deleteItem(_ instance: T) -> Bool { + + if !self.hasLoaded { + self._addPendingOperation(method: .addOrUpdate, instance: instance) + return false + } + instance.deleteDependencies() self.items.removeAll { $0.id == instance.id } self._indexes?.removeValue(forKey: instance.id) + return true } + fileprivate func _addPendingOperation(method: StorageMethod, instance: T) { + if self._pendingOperationManager == nil { + self._pendingOperationManager = PendingOperationManager(store: self.store, inMemory: self.inMemory) + } + self._pendingOperationManager?.addPendingOperation(method: method, instance: instance) + } + /// If the collection has more instance that its limit, remove the surplus fileprivate func _applyLimitIfPresent() { if let limit { @@ -323,13 +373,6 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self.delete(contentOfs: self.items) } - // MARK: - SomeCall - - /// Returns the collection items as [any Storable] - func allItems() -> [any Storable] { - return self.items - } - // MARK: - File access /// Schedules a write operation @@ -355,7 +398,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } /// Simply clears the items of the collection - func clear() { + public func clear() { self.items.removeAll() } @@ -363,14 +406,16 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti public func reset() { self.items.removeAll() self.store.removeFile(type: T.self) + setChanged() + self.hasLoaded = false } - var type: any Storable.Type { return T.self } + public var type: any Storable.Type { return T.self } // MARK: - Reference count /// Counts the references to an object - given its type and id - inside the collection - func referenceCount(type: S.Type, id: String) -> Int { + public func referenceCount(type: S.Type, id: String) -> Int { let relationships = T.relationships().filter { $0.type == type } guard relationships.count > 0 else { return 0 } @@ -382,6 +427,15 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } } +} + +public class StoredCollection: BaseCollection, RandomAccessCollection { + + /// Returns a dummy StoredCollection instance + public static func placeholder() -> StoredCollection { + return StoredCollection() + } + // MARK: - RandomAccessCollection public var startIndex: Int { return self.items.startIndex } @@ -403,3 +457,24 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } } + +extension SyncedCollection: RandomAccessCollection { + + public var startIndex: Int { return self.items.startIndex } + + public var endIndex: Int { return self.items.endIndex } + + public func index(after i: Int) -> Int { + return self.items.index(after: i) + } + + public subscript(index: Int) -> T { + get { + return self.items[index] + } + set(newValue) { + self.items[index] = newValue + self._hasChanged = true + } + } +} diff --git a/LeStorage/Codables/PendingOperation.swift b/LeStorage/Codables/PendingOperation.swift new file mode 100644 index 0000000..e3c64ed --- /dev/null +++ b/LeStorage/Codables/PendingOperation.swift @@ -0,0 +1,34 @@ +// +// WaitingOperation.swift +// LeStorage +// +// Created by Laurent Morvillier on 01/04/2025. +// + +import Foundation + +enum StorageMethod: String, Codable { + case addOrUpdate + case delete +} + +class PendingOperation: Codable, Equatable { + + var id: String = Store.randomId() + var method: StorageMethod + var data: T +// var modelName: String +// var storeId: String? + + init(method: StorageMethod, data: T) { +// self.modelName = modelName +// self.storeId = storeId + self.method = method + self.data = data + } + + static func == (lhs: PendingOperation, rhs: PendingOperation) -> Bool { + return lhs.id == rhs.id + } + +} diff --git a/LeStorage/PendingOperationManager.swift b/LeStorage/PendingOperationManager.swift new file mode 100644 index 0000000..16f370d --- /dev/null +++ b/LeStorage/PendingOperationManager.swift @@ -0,0 +1,62 @@ +// +// PendingOperationManager.swift +// LeStorage +// +// Created by Laurent Morvillier on 01/04/2025. +// + +import Foundation + +class PendingOperationManager { + + fileprivate(set) var items: [PendingOperation] = [] + + fileprivate var _fileName: String = "pending_\(T.resourceName())" + + fileprivate var _inMemory: Bool = false + + init(store: Store, inMemory: Bool) { + self._inMemory = inMemory + if !inMemory { + do { + let url = try store.fileURL(fileName: self._fileName) + if FileManager.default.fileExists(atPath: url.path()) { + let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName) + if let decoded: [PendingOperation] = try jsonString.decode() { + self.items = decoded + } + } + } catch { + Logger.error(error) + } + } + } + + func addPendingOperation(method: StorageMethod, instance: T) { + Logger.log("addPendingOperation: \(method), \(instance)") + + let operation = PendingOperation(method: method, data: instance) + self.items.append(operation) + + self._writeIfNecessary() + } + + func reset() { + self.items.removeAll() + self._writeIfNecessary() + } + + fileprivate func _writeIfNecessary() { + guard !self._inMemory else { return } + + Task(priority: .background) { + do { + let jsonString: String = try self.items.jsonString() + let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: self._fileName) + } catch { + Logger.error(error) + } + } + } + +} diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index d1fc9eb..56064e8 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -46,7 +46,7 @@ final public class Store { /// The Store singleton public static let main = Store() - /// The dictionary of registered StoredCollections + /// The dictionary of registered collections fileprivate var _collections: [String : any SomeCollection] = [:] /// The name of the directory to store the json files @@ -83,7 +83,7 @@ final public class Store { /// - inMemory: Indicates if the collection should only live in memory, and not write into a file public func registerCollection(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection { - if let collection: StoredCollection = try? self.collection() { + if let collection: StoredCollection = try? self.collection() as? StoredCollection { return collection } @@ -97,13 +97,13 @@ final public class Store { /// - Parameters: /// - indexed: Creates an index to quickly access the data /// - inMemory: Indicates if the collection should only live in memory, and not write into a file - public func registerSynchronizedCollection(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection { + public func registerSynchronizedCollection(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> SyncedCollection { - if let collection: StoredCollection = try? self.collection() { + if let collection: SyncedCollection = try? self.syncedCollection() { return collection } - let collection = StoredCollection(store: self, indexed: indexed, inMemory: inMemory, limit: limit) + let collection = SyncedCollection(store: self, indexed: indexed, inMemory: inMemory, limit: limit) self._collections[T.resourceName()] = collection StoreCenter.main.loadApiCallCollection(type: T.self) return collection @@ -132,7 +132,7 @@ final public class Store { /// - Parameters: /// - id: the id of the data public func findById(_ id: T.ID) -> T? { - guard let collection = self._collections[T.resourceName()] as? StoredCollection else { + guard let collection = self._collections[T.resourceName()] as? BaseCollection else { Logger.w("Collection \(T.resourceName()) not registered") return nil } @@ -142,25 +142,33 @@ final public class Store { /// Filters a collection by predicate /// - Parameters: /// - isIncluded: a predicate to returns if a data should be filtered in - public func filter(isIncluded: (T) throws -> (Bool)) rethrows -> [T] { - do { - return try self.collection().filter(isIncluded) - } catch { - return [] +// public func filter(isIncluded: (T) throws -> (Bool)) rethrows -> [T] { +// do { +// return try self.collection().filter(isIncluded) +// } catch { +// return [] +// } +// } + + /// Returns a collection by type + func syncedCollection() throws -> SyncedCollection { + if let collection = self._collections[T.resourceName()] as? SyncedCollection { + return collection } + throw StoreError.collectionNotRegistered(type: T.resourceName()) } /// Returns a collection by type - func collection() throws -> StoredCollection { - if let collection = self._collections[T.resourceName()] as? StoredCollection { + func collection() throws -> BaseCollection { + if let collection = self._collections[T.resourceName()] as? BaseCollection { return collection } throw StoreError.collectionNotRegistered(type: T.resourceName()) } - func registerOrGetSyncedCollection(_ type: T.Type) -> StoredCollection { + func registerOrGetSyncedCollection(_ type: T.Type) -> SyncedCollection { do { - return try self.collection() + return try self.syncedCollection() } catch { return self.registerSynchronizedCollection(indexed: true, inMemory: false) } @@ -204,14 +212,14 @@ final public class Store { /// Calls addOrUpdateIfNewer from the collection corresponding to the instance func addOrUpdateIfNewer(_ instance: T, shared: Bool) { - let collection: StoredCollection = self.registerOrGetSyncedCollection(T.self) + let collection: SyncedCollection = self.registerOrGetSyncedCollection(T.self) collection.addOrUpdateIfNewer(instance, shared: shared) } /// Calls deleteById from the collection corresponding to the instance func deleteNoSync(instance: T) { do { - let collection: StoredCollection = try self.collection() + let collection: BaseCollection = try self.collection() collection.delete(instance: instance) } catch { Logger.error(error) @@ -220,7 +228,7 @@ final public class Store { /// Calls deleteById from the collection corresponding to the instance func deleteNoSync(type: T.Type, id: String) throws { - let collection: StoredCollection = try self.collection() + let collection: SyncedCollection = try self.syncedCollection() collection.deleteByStringIdNoSync(id) } @@ -258,8 +266,15 @@ final public class Store { /// - Parameters: /// - type: a Storable type func fileURL(type: T.Type) throws -> URL { + return try self.fileURL(fileName: T.fileName()) + } + + /// Returns the URL matching a Storable type + /// - Parameters: + /// - type: a Storable type + func fileURL(fileName: String) throws -> URL { let fileURL = try self._directoryPath() - return fileURL.appending(component: T.fileName()) + return fileURL.appending(component: fileName) } /// Removes a file matching a Storable type @@ -287,7 +302,7 @@ final public class Store { func loadCollectionItems(_ items: [T]) async { do { - let collection: StoredCollection = try self.collection() + let collection: BaseCollection = try self.collection() await collection.clearAndLoadItems(items) } catch { Logger.error(error) diff --git a/LeStorage/StoreCenter.swift b/LeStorage/StoreCenter.swift index 56ef29d..be59d97 100644 --- a/LeStorage/StoreCenter.swift +++ b/LeStorage/StoreCenter.swift @@ -35,20 +35,20 @@ public class StoreCenter { /// The WebSocketManager that manages realtime synchronization fileprivate var _webSocketManager: WebSocketManager? - /// The dictionary of registered StoredCollections + /// The dictionary of registered api collections fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:] /// A collection of DataLog objects, used for the synchronization fileprivate var _dataLogs: StoredCollection /// A synchronized collection of DataAccess - fileprivate var _dataAccess: StoredCollection? = nil + fileprivate var _dataAccess: SyncedCollection? = nil /// A collection storing FailedAPICall objects - fileprivate var _failedAPICallsCollection: StoredCollection? = nil + fileprivate var _failedAPICallsCollection: SyncedCollection? = nil /// A collection of Log objects - fileprivate var _logs: StoredCollection? = nil + fileprivate var _logs: SyncedCollection? = nil /// A list of username that cannot synchronize with the server fileprivate var _blackListedUserName: [String] = [] @@ -223,6 +223,7 @@ public class StoreCenter { self._stores.removeAll() self._dataAccess?.reset() + self._dataLogs.reset() self._settingsStorage.update { settings in settings.username = nil @@ -362,7 +363,7 @@ public class StoreCenter { /// Resets the ApiCall whose type identifies with the provided collection /// - Parameters: /// - collection: The collection identifying the Storable type - public func resetApiCalls(collection: StoredCollection) { + public func resetApiCalls(type: T.Type) { do { let apiCallCollection: ApiCallCollection = try self.apiCallCollection() Task { @@ -372,7 +373,7 @@ public class StoreCenter { Logger.error(error) } } - + // MARK: - Api call rescheduling /// Retry API calls immediately @@ -522,8 +523,14 @@ public class StoreCenter { } func sendGetRequest(_ type: T.Type, storeId: String?) async throws { - let apiCallCollection: ApiCallCollection = try self.apiCallCollection() - try await apiCallCollection.sendGetRequest(storeId: storeId) + if self.canPerformGet(T.self) { + let apiCallCollection: ApiCallCollection = try self.apiCallCollection() + try await apiCallCollection.sendGetRequest(storeId: storeId) + } + } + + func canPerformGet(_ type: T.Type) -> Bool { + return T.tokenExemptedMethods().contains(where: { $0 == .get }) || self.isAuthenticated } /// Processes Data Access data @@ -829,7 +836,7 @@ public class StoreCenter { /// This method triggers the framework to save and send failed api calls public func logsFailedAPICalls() { - self._failedAPICallsCollection = Store.main.registerCollection(limit: 50) + self._failedAPICallsCollection = Store.main.registerSynchronizedCollection(limit: 50) } /// If configured for, logs and send to the server a failed API call @@ -918,29 +925,20 @@ public class StoreCenter { /// Updates a local object with a server instance func updateLocalInstances(_ results: [T]) { for result in results { - if let storedCollection: StoredCollection = self.collectionOfInstance(result) { - if storedCollection.findById(result.id) != nil { - storedCollection.updateFromServerInstance(result) + if let syncedCollection: SyncedCollection = self.collectionOfInstance(result) as? SyncedCollection { + if syncedCollection.findById(result.id) != nil { + syncedCollection.updateFromServerInstance(result) } } } } - /// Updates a local object with a server instance -// func updateFromServerInstance(_ result: T) { -// if let storedCollection: StoredCollection = self.collectionOfInstance(result) { -// if storedCollection.findById(result.id) != nil { -// storedCollection.updateFromServerInstance(result) -// } -// } -// } - /// Returns the collection hosting an instance - func collectionOfInstance(_ instance: T) -> StoredCollection? { + func collectionOfInstance(_ instance: T) -> BaseCollection? { do { - let storedCollection: StoredCollection = try Store.main.collection() - if storedCollection.findById(instance.id) != nil { - return storedCollection + let collection: BaseCollection = try Store.main.collection() + if collection.findById(instance.id) != nil { + return collection } else { return self.collectionOfInstanceInSubStores(instance) } @@ -950,11 +948,11 @@ public class StoreCenter { } /// Search inside the additional stores to find the collection hosting the instance - func collectionOfInstanceInSubStores(_ instance: T) -> StoredCollection? { + func collectionOfInstanceInSubStores(_ instance: T) -> BaseCollection? { for store in self._stores.values { - let storedCollection: StoredCollection? = try? store.collection() - if storedCollection?.findById(instance.id) != nil { - return storedCollection + let collection: BaseCollection? = try? store.collection() + if collection?.findById(instance.id) != nil { + return collection } } return nil @@ -998,11 +996,11 @@ public class StoreCenter { // MARK: - Logs /// Returns the logs collection and instantiates it if necessary - fileprivate func _logsCollection() -> StoredCollection { + fileprivate func _logsCollection() -> SyncedCollection { if let logs = self._logs { return logs } else { - let logsCollection: StoredCollection = Store.main.registerCollection(limit: 50) + let logsCollection: SyncedCollection = Store.main.registerSynchronizedCollection(limit: 50) self._logs = logsCollection return logsCollection } diff --git a/LeStorage/StoredSingleton.swift b/LeStorage/StoredSingleton.swift index 672020e..89f439d 100644 --- a/LeStorage/StoredSingleton.swift +++ b/LeStorage/StoredSingleton.swift @@ -8,7 +8,7 @@ import Foundation /// A class extending the capabilities of StoredCollection but supposedly manages only one item -public class StoredSingleton: StoredCollection { +public class StoredSingleton: SyncedCollection { /// Sets the singleton to the collection without synchronizing it public func setItemNoSync(_ instance: T) { diff --git a/LeStorage/StoredCollection+Sync.swift b/LeStorage/SyncedCollection.swift similarity index 79% rename from LeStorage/StoredCollection+Sync.swift rename to LeStorage/SyncedCollection.swift index a827b69..b0cf7d7 100644 --- a/LeStorage/StoredCollection+Sync.swift +++ b/LeStorage/SyncedCollection.swift @@ -1,5 +1,5 @@ // -// StoredCollection.swift +// SyncedCollection.swift // LeStorage // // Created by Laurent Morvillier on 11/10/2024. @@ -7,15 +7,20 @@ import Foundation -extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { +public class SyncedCollection: BaseCollection, SomeSyncedCollection { + /// Returns a dummy SyncedCollection instance + public static func placeholder() -> SyncedCollection { + return SyncedCollection() + } + /// Migrates if necessary and asynchronously decodes the json file - func load() async { + override func load() async { do { if self.inMemory { try await self.loadDataFromServerIfAllowed() } else { - try self.loadFromFile() + await self.loadFromFile() } } catch { Logger.error(error) @@ -41,26 +46,13 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { } do { try await StoreCenter.main.sendGetRequest(T.self, storeId: self.storeId) - - - -// let items: [T] = try await self.store.getItems() -// if items.count > 0 { -// DispatchQueue.main.async { -// if clear { -// self.clear() -// } -// self.addOrUpdateNoSync(contentOfs: items) -// } -// } -// self.setAsLoaded() } catch { Logger.error(error) } } /// Updates a local item from a server instance. This method is typically used when the server makes update - /// to an object when it's inserted. The StoredCollection possibly needs to update its own copy with new values. + /// to an object when it's inserted. The SyncedCollection possibly needs to update its own copy with new values. /// - serverInstance: the instance of the object on the server func updateFromServerInstance(_ serverInstance: T) { @@ -72,10 +64,6 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { if let localInstance = self.findById(serverInstance.id) { localInstance.copy(from: serverInstance) self.setChanged() -// let modified = localInstance.copyFromServerInstance(serverInstance) -// if modified { -// self.setChanged() -// } } } } @@ -83,24 +71,24 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { // MARK: - Basic operations with sync /// Adds or update an instance and writes - public func addOrUpdate(instance: T) { -// Logger.log("\(T.resourceName()) : one item") - defer { - self.setChanged() - } + public override func addOrUpdate(instance: T) { instance.lastUpdate = Date() if let index = self.items.firstIndex(where: { $0.id == instance.id }) { - self.updateItem(instance, index: index) - self._sendUpdate(instance) + if self.updateItem(instance, index: index) { + self._sendUpdate(instance) + self.setChanged() + } } else { - self.addItem(instance: instance) - self._sendInsertion(instance) + if self.addItem(instance: instance) { + self._sendInsertion(instance) + self.setChanged() + } } } /// Adds or update a sequence and writes - public func addOrUpdate(contentOfs sequence: any Sequence) { + override public func addOrUpdate(contentOfs sequence: any Sequence) { // Logger.log("\(T.resourceName()) : \(sequence.underestimatedCount) items") defer { self.setChanged() @@ -112,13 +100,13 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { for instance in sequence { instance.lastUpdate = date if let index = self.items.firstIndex(where: { $0.id == instance.id }) { - self.updateItem(instance, index: index) - batch.addUpdate(instance) -// self._sendUpdateIfNecessary(instance) + if self.updateItem(instance, index: index) { + batch.addUpdate(instance) + } } else { // insert - self.addItem(instance: instance) - batch.addInsert(instance) -// self._sendInsertionIfNecessary(instance) + if self.addItem(instance: instance) { + batch.addInsert(instance) + } } } @@ -127,12 +115,12 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { } /// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls - public func deleteAll() throws { + override public func deleteAll() throws { self.delete(contentOfs: self.items) } /// Deletes all items of the sequence by id and sets the collection as changed to trigger a write - public func delete(contentOfs sequence: any RandomAccessCollection) { + override public func delete(contentOfs sequence: any RandomAccessCollection) { defer { self.setChanged() @@ -140,19 +128,22 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { guard sequence.isNotEmpty else { return } + var deleted: [T] = [] + for instance in sequence { -// print(">>> SEND DELETE for \(instance.id)") - self.deleteItem(instance) + if self.deleteItem(instance) { + deleted.append(instance) + } StoreCenter.main.createDeleteLog(instance) } let batch = OperationBatch() - batch.deletes = Array(sequence) + batch.deletes = deleted self._sendOperationBatch(batch) } /// Deletes an instance and writes - public func delete(instance: T) { + override public func delete(instance: T) { defer { self.setChanged() } @@ -169,7 +160,7 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { public func deleteDependencies(_ items: any RandomAccessCollection) { guard items.isNotEmpty else { return } - delete(contentOfs: items) // MUST NOT ADD "self" before delete, otherwise it will call the delete method of StoredCollection without sync + self.delete(contentOfs: items) } // MARK: - Basic operations without sync @@ -221,12 +212,6 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { Task { do { try await StoreCenter.main.sendOperationBatch(batch) -// let success = try await StoreCenter.main.sendOperationBatch(batch) -// for item in success { -// if let data = item.data { -// self.updateFromServerInstance(data) -// } -// } } catch { Logger.error(error) } diff --git a/LeStorageTests/ApiCallTests.swift b/LeStorageTests/ApiCallTests.swift index b2c594f..87e46ec 100644 --- a/LeStorageTests/ApiCallTests.swift +++ b/LeStorageTests/ApiCallTests.swift @@ -8,7 +8,8 @@ import Testing @testable import LeStorage -class Thing: ModelObject, Storable { +class Thing: SyncedModelObject, SyncedStorable, URLParameterConvertible { + static func resourceName() -> String { return "thing" } static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } static func filterByStoreIdentifier() -> Bool { return false } @@ -18,7 +19,22 @@ class Thing: ModelObject, Storable { init(name: String) { self.name = name + super.init() + } + + required init(from decoder: any Decoder) throws { + fatalError("init(from:) has not been implemented") + } + func copy(from other: any LeStorage.Storable) { + } + + static func relationships() -> [LeStorage.Relationship] { return [] } + + func queryParameters() -> [String : String] { + return ["yeah?" : "god!"] + } + } struct ApiCallTests { @@ -87,4 +103,25 @@ struct ApiCallTests { await #expect(collection.items.count == 1) } + @Test func testGetProvisioning() async throws { + let collection = ApiCallCollection() + + try await collection.sendGetRequest(storeId: "1") + await #expect(collection.items.count == 1) + try await collection.sendGetRequest(storeId: "1") + await #expect(collection.items.count == 1) + + try await collection.sendGetRequest(storeId: "2") + await #expect(collection.items.count == 2) + + try await collection.sendGetRequest(instance: Thing(name: "man!")) + await #expect(collection.items.count == 3) + + try await collection.sendGetRequest(storeId: nil) + await #expect(collection.items.count == 4) + try await collection.sendGetRequest(storeId: nil) + await #expect(collection.items.count == 4) + + } + } diff --git a/LeStorageTests/CollectionsTests.swift b/LeStorageTests/CollectionsTests.swift index bd95d6d..cf8cf8b 100644 --- a/LeStorageTests/CollectionsTests.swift +++ b/LeStorageTests/CollectionsTests.swift @@ -13,32 +13,68 @@ class Car: ModelObject, Storable { var id: String = Store.randomId() static func resourceName() -> String { return "car" } - static var relationshipNames: [String] = [] + func copy(from other: any LeStorage.Storable) { + + } + static func relationships() -> [LeStorage.Relationship] { return [] } + } class Boat: ModelObject, SyncedStorable { var id: String = Store.randomId() var lastUpdate: Date = Date() - + var shared: Bool? + static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } static func resourceName() -> String { return "boat" } - static var relationshipNames: [String] = [] var storeId: String? { return nil } + func copy(from other: any LeStorage.Storable) { + + } + static func relationships() -> [LeStorage.Relationship] { return [] } + } struct CollectionsTests { - + + var cars: StoredCollection + var boats: SyncedCollection + + init() { + cars = Store.main.registerCollection(inMemory: true) + boats = Store.main.registerSynchronizedCollection(inMemory: true) + } + + func ensureCollectionLoaded(_ collection: any SomeCollection) async throws { + // Wait for the collection to finish loading + // Adjust the timeout as needed + let timeout = 5.0 // seconds + let startTime = Date() + + while !collection.hasLoaded { + // Check for timeout + if Date().timeIntervalSince(startTime) > timeout { + throw Error("Collection loading timed out") + } + // Wait a bit before checking again + try await Task.sleep(for: .milliseconds(100)) + } + } + @Test func differentiationTest() async throws { - let cars: StoredCollection = Store.main.registerCollection(inMemory: true) - let boats: StoredCollection = Store.main.registerSynchronizedCollection(inMemory: true) + try await ensureCollectionLoaded(cars) + try await ensureCollectionLoaded(boats) + // Cars #expect(cars.count == 0) cars.addOrUpdate(instance: Car()) #expect(cars.count == 1) + // Boats + #expect(boats.count == 0) let oldApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self) @@ -49,6 +85,7 @@ struct CollectionsTests { #expect(oldApiCallCount == newApiCallCount - 1) + // Cars and boats cars.reset() boats.reset() #expect(cars.count == 0) diff --git a/LeStorageTests/IdentifiableTests.swift b/LeStorageTests/IdentifiableTests.swift index eb49cde..76d5661 100644 --- a/LeStorageTests/IdentifiableTests.swift +++ b/LeStorageTests/IdentifiableTests.swift @@ -9,9 +9,9 @@ import Testing import LeStorage class IntObject: ModelObject, Storable { + static func resourceName() -> String { "int" } static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] } - static var relationshipNames: [String] = [] var id: Int var name: String @@ -20,6 +20,13 @@ class IntObject: ModelObject, Storable { self.id = id self.name = name } + func copy(from other: any LeStorage.Storable) { + } + + static func relationships() -> [LeStorage.Relationship] { + return [] + } + } class StringObject: ModelObject, Storable { @@ -34,15 +41,47 @@ class StringObject: ModelObject, Storable { self.id = id self.name = name } + func copy(from other: any LeStorage.Storable) { + } + + static func relationships() -> [LeStorage.Relationship] { + return [] + } + } struct IdentifiableTests { + let intObjects: StoredCollection + let stringObjects: StoredCollection + + init() { + intObjects = Store.main.registerCollection() + stringObjects = Store.main.registerCollection() + } + + func ensureCollectionLoaded(_ collection: any SomeCollection) async throws { + // Wait for the collection to finish loading + // Adjust the timeout as needed + let timeout = 5.0 // seconds + let startTime = Date() + + while !collection.hasLoaded { + // Check for timeout + if Date().timeIntervalSince(startTime) > timeout { + throw Error("Collection loading timed out") + } + // Wait a bit before checking again + try await Task.sleep(for: .milliseconds(100)) + } + } + @Test func testIntIds() async throws { - let intObjects: StoredCollection = Store.main.registerCollection() + + try await ensureCollectionLoaded(self.intObjects) let int = IntObject(id: 12, name: "test") - intObjects.addOrUpdate(instance: int) + self.intObjects.addOrUpdate(instance: int) if let search = intObjects.findById(12) { #expect(search.id == 12) @@ -52,10 +91,10 @@ struct IdentifiableTests { } @Test func testStringIds() async throws { - let stringObjects: StoredCollection = Store.main.registerCollection() + try await ensureCollectionLoaded(self.stringObjects) let string = StringObject(id: "coco", name: "name") - stringObjects.addOrUpdate(instance: string) + self.stringObjects.addOrUpdate(instance: string) if let search = stringObjects.findById("coco") { #expect(search.id == "coco") diff --git a/LeStorageTests/StoredCollectionTests.swift b/LeStorageTests/StoredCollectionTests.swift index 85cd894..7c11ea4 100644 --- a/LeStorageTests/StoredCollectionTests.swift +++ b/LeStorageTests/StoredCollectionTests.swift @@ -4,86 +4,120 @@ // // Created by Laurent Morvillier on 16/10/2024. // -import XCTest -@testable import LeStorage +import Testing +import LeStorage -class StoredCollectionTests: XCTestCase { +struct Error: Swift.Error, CustomStringConvertible { + let description: String + + init(_ description: String) { + self.description = description + } +} - var collection: StoredCollection! +struct StoredCollectionTests { - override func setUp() { - super.setUp() - self.collection = Store.main.registerCollection() + var collection: StoredCollection + + init() { + collection = Store.main.registerCollection() } - - override func tearDown() { - self.collection.clear() - super.tearDown() + + func ensureCollectionLoaded() async throws { + // Wait for the collection to finish loading + // Adjust the timeout as needed + let timeout = 5.0 // seconds + let startTime = Date() + + while !collection.hasLoaded { + // Check for timeout + if Date().timeIntervalSince(startTime) > timeout { + throw Error("Collection loading timed out") + } + // Wait a bit before checking again + try await Task.sleep(for: .milliseconds(100)) + } } - - func testInitialization() { - XCTAssertEqual(collection.items.count, 0) + + @Test func testInitialization() async throws { + try await ensureCollectionLoaded() + #expect(collection.items.count == 0) } - func testAddOrUpdate() throws { + @Test func testAddOrUpdate() async throws { + try await ensureCollectionLoaded() let item = MockStorable(id: "1", name: "Test") collection.addOrUpdate(instance: item) - XCTAssertEqual(collection.items.count, 1) - XCTAssertEqual(collection.items[0].id, "1") + #expect(collection.items.count == 1) + if let first = collection.items.first { + #expect(first.id == "1") + } else { + Issue.record("missing record") + } + } - func testDelete() throws { + @Test func testDelete() async throws { + try await ensureCollectionLoaded() let item = MockStorable(id: "1", name: "Test") collection.addOrUpdate(instance: item) - XCTAssertEqual(collection.items.count, 1) + #expect(collection.items.count == 1) - try collection.delete(instance: item) - XCTAssertEqual(collection.items.count, 0) + collection.delete(instance: item) + #expect(collection.items.isEmpty) } - func testFindById() throws { + @Test func testFindById() async throws { + try await ensureCollectionLoaded() let item = MockStorable(id: "1", name: "Test") collection.addOrUpdate(instance: item) - let foundItem = collection.findById("1") - XCTAssertNotNil(foundItem) - XCTAssertEqual(foundItem?.id, "1") + if let foundItem = collection.findById("1") { + #expect(foundItem.id == "1") + } else { + Issue.record("missing item") + } } - func testDeleteById() throws { + @Test func testDeleteById() async throws { + try await ensureCollectionLoaded() let item = MockStorable(id: "1", name: "Test") collection.addOrUpdate(instance: item) - try collection.deleteById("1") - XCTAssertNil(collection.findById("1")) + collection.deleteById("1") + let search = collection.findById("1") + #expect(search == nil) } - func testAddOrUpdateMultiple() throws { + @Test func testAddOrUpdateMultiple() async throws { + try await ensureCollectionLoaded() let items = [ MockStorable(id: "1", name: "Test1"), MockStorable(id: "2", name: "Test2"), ] collection.addOrUpdate(contentOfs: items) - XCTAssertEqual(collection.items.count, 2) + #expect(collection.items.count == 2) } - func testDeleteAll() throws { + @Test func testDeleteAll() async throws { + try await ensureCollectionLoaded() let items = [ MockStorable(id: "1", name: "Test1"), MockStorable(id: "2", name: "Test2"), ] collection.addOrUpdate(contentOfs: items) - XCTAssertEqual(collection.items.count, 2) + #expect(collection.items.count == 2) collection.clear() - XCTAssertEqual(collection.items.count, 0) + #expect(collection.items.isEmpty) } - func testRandomAccessCollection() { + @Test func testRandomAccessCollection() async throws { + try await ensureCollectionLoaded() let items = [ MockStorable(id: "1", name: "Test1"), MockStorable(id: "2", name: "Test2"), @@ -92,9 +126,15 @@ class StoredCollectionTests: XCTestCase { collection.addOrUpdate(contentOfs: items) - XCTAssertEqual(collection.startIndex, 0) - XCTAssertEqual(collection.endIndex, 3) - XCTAssertEqual(collection[1].name, "Test2") + #expect(collection.startIndex == 0) + #expect(collection.endIndex == 3) + + if collection.count > 2 { + #expect(collection[1].name == "Test2") + } else { + Issue.record("count not good") + } + } } @@ -112,5 +152,11 @@ class MockStorable: ModelObject, Storable { static func resourceName() -> String { return "mocks" } - + func copy(from other: any LeStorage.Storable) { + } + + static func relationships() -> [LeStorage.Relationship] { + return [] + } + }