Make collection loading asynchronous + manage state when collection are not loaded + separate StoredCollection from synced collection

sync_v2
Laurent 7 months ago
parent eab91fcd8c
commit 27a403c99b
  1. 24
      LeStorage.xcodeproj/project.pbxproj
  2. 97
      LeStorage/ApiCallCollection.swift
  3. 167
      LeStorage/BaseCollection.swift
  4. 34
      LeStorage/Codables/PendingOperation.swift
  5. 62
      LeStorage/PendingOperationManager.swift
  6. 55
      LeStorage/Store.swift
  7. 58
      LeStorage/StoreCenter.swift
  8. 2
      LeStorage/StoredSingleton.swift
  9. 87
      LeStorage/SyncedCollection.swift
  10. 39
      LeStorageTests/ApiCallTests.swift
  11. 45
      LeStorageTests/CollectionsTests.swift
  12. 49
      LeStorageTests/IdentifiableTests.swift
  13. 116
      LeStorageTests/StoredCollectionTests.swift

@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400D7222CC2AF560092237C /* GetSyncData.swift */; }; 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 */; }; 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, ); }; }; 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 */; }; 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 */; }; C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; };
C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = C462E0DB2D37B61100F3E6E4 /* Notification+Name.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 */; }; 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 */; }; C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; };
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.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 */; }; 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 */; }; C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; };
C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D522B6D2C5F00ADC637 /* Logger.swift */; }; C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D522B6D2C5F00ADC637 /* Logger.swift */; };
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D542B6D2DBF00ADC637 /* FileUtils.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 */; }; C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477962CB66EEA0077713D /* Date+Extensions.swift */; };
C4D4779D2CB923720077713D /* DataLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779C2CB923720077713D /* DataLog.swift */; }; C4D4779D2CB923720077713D /* DataLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779C2CB923720077713D /* DataLog.swift */; };
C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779E2CB92FD80077713D /* SyncedStorable.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 */; }; C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE6992CEB84B300790446 /* WebSocketManager.swift */; };
C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE69B2CEB8E9500790446 /* URLManager.swift */; }; C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE69B2CEB8E9500790446 /* URLManager.swift */; };
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */; }; C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */; };
@ -61,6 +63,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
C400D7222CC2AF560092237C /* GetSyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSyncData.swift; sourceTree = "<group>"; }; C400D7222CC2AF560092237C /* GetSyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSyncData.swift; sourceTree = "<group>"; };
C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperationManager.swift; sourceTree = "<group>"; };
C425D4342B6D24E1002A7B48 /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; C425D4372B6D24E1002A7B48 /* LeStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LeStorage.h; sourceTree = "<group>"; };
C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = "<group>"; }; C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = "<group>"; };
@ -70,10 +73,11 @@
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; }; C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; };
C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; }; C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = "<group>"; }; C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = "<group>"; };
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.swift; sourceTree = "<group>"; };
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; }; C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; }; C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; };
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; }; C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; }; C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCollection.swift; sourceTree = "<group>"; };
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; }; C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; };
C4A47D522B6D2C5F00ADC637 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; }; C4A47D522B6D2C5F00ADC637 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; }; C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
@ -96,7 +100,7 @@
C4D477962CB66EEA0077713D /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; }; C4D477962CB66EEA0077713D /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.swift; sourceTree = "<group>"; }; C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.swift; sourceTree = "<group>"; };
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedStorable.swift; sourceTree = "<group>"; }; C4D4779E2CB92FD80077713D /* SyncedStorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedStorable.swift; sourceTree = "<group>"; };
C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredCollection+Sync.swift"; sourceTree = "<group>"; }; C4D477A02CB9586A0077713D /* SyncedCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedCollection.swift; sourceTree = "<group>"; };
C4FAE6992CEB84B300790446 /* WebSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketManager.swift; sourceTree = "<group>"; }; C4FAE6992CEB84B300790446 /* WebSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketManager.swift; sourceTree = "<group>"; };
C4FAE69B2CEB8E9500790446 /* URLManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLManager.swift; sourceTree = "<group>"; }; C4FAE69B2CEB8E9500790446 /* URLManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLManager.swift; sourceTree = "<group>"; };
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = "<group>"; }; C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = "<group>"; };
@ -155,13 +159,14 @@
C4A47D6C2B71364600ADC637 /* ModelObject.swift */, C4A47D6C2B71364600ADC637 /* ModelObject.swift */,
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */, C488C87F2CCBDC210082001F /* NetworkMonitor.swift */,
C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */, C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */,
C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */,
C4AC9CE92CF754CC00CC13DF /* Relationship.swift */, C4AC9CE92CF754CC00CC13DF /* Relationship.swift */,
C4A47D602B6D3C1300ADC637 /* Services.swift */, C4A47D602B6D3C1300ADC637 /* Services.swift */,
C425D4572B6D2519002A7B48 /* Store.swift */, C425D4572B6D2519002A7B48 /* Store.swift */,
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */, C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */,
C4A47D642B6E92FE00ADC637 /* Storable.swift */, C4A47D642B6E92FE00ADC637 /* Storable.swift */,
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */, C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */,
C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */, C4D477A02CB9586A0077713D /* SyncedCollection.swift */,
C456EFE12BE52379007388E2 /* StoredSingleton.swift */, C456EFE12BE52379007388E2 /* StoredSingleton.swift */,
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */, C4D4779E2CB92FD80077713D /* SyncedStorable.swift */,
C4FAE6992CEB84B300790446 /* WebSocketManager.swift */, C4FAE6992CEB84B300790446 /* WebSocketManager.swift */,
@ -210,6 +215,7 @@
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */, C4A47D9A2B7CFFC500ADC637 /* Settings.swift */,
C400D7222CC2AF560092237C /* GetSyncData.swift */, C400D7222CC2AF560092237C /* GetSyncData.swift */,
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */, C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */,
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */,
); );
path = Codables; path = Codables;
sourceTree = "<group>"; sourceTree = "<group>";
@ -332,8 +338,9 @@
C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */, C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */,
C4A47D842B7B97F000ADC637 /* KeychainStore.swift in Sources */, C4A47D842B7B97F000ADC637 /* KeychainStore.swift in Sources */,
C4FC2E312C353E7B0021F3BF /* Log.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 */, C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */,
C40EC3E52D9BDFA3007372D7 /* PendingOperationManager.swift in Sources */,
C4AC9CE52CEFB12100CC13DF /* DataAccess.swift in Sources */, C4AC9CE52CEFB12100CC13DF /* DataAccess.swift in Sources */,
C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */, C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */,
C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */, C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */,
@ -351,7 +358,7 @@
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */, C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */,
C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */, C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */,
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */, C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */, C4A47D4F2B6D280200ADC637 /* BaseCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */, C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */,
C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */, C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */,
@ -362,6 +369,7 @@
C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */, C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */,
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */, C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */,
C425D4582B6D2519002A7B48 /* Store.swift in Sources */, C425D4582B6D2519002A7B48 /* Store.swift in Sources */,
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */,
C4D4779D2CB923720077713D /* DataLog.swift in Sources */, C4D4779D2CB923720077713D /* DataLog.swift in Sources */,
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */, C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */,
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */, C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */,

@ -141,6 +141,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Removes all objects in memory and deletes the JSON file /// Removes all objects in memory and deletes the JSON file
func reset() { func reset() {
self._isExecutingCalls = false self._isExecutingCalls = false
self._schedulingTask?.cancel()
self.items.removeAll() self.items.removeAll()
do { do {
@ -255,7 +256,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// The method makes some clean up when necessary: /// The method makes some clean up when necessary:
/// - When deleting, we delete other calls as they are unecessary /// - 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 /// - 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<T> { func callForInstance(_ instance: T, method: HTTPMethod, transactionId: String? = nil) -> ApiCall<T> {
// cleanup if necessary // cleanup if necessary
switch method { switch method {
@ -269,24 +270,31 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
break break
} }
let call: ApiCall<T> = try self._createCall(method, instance: instance, transactionId: transactionId) let call: ApiCall<T> = self._createCall(method, instance: instance, transactionId: transactionId)
self._prepareCall(apiCall: call) self._prepareCall(apiCall: call)
return call return call
} }
/// deletes an array of ApiCall by id
fileprivate func _deleteCalls(_ calls: [ApiCall<T>]) { fileprivate func _deleteCalls(_ calls: [ApiCall<T>]) {
for call in calls { for call in calls {
self.deleteById(call.id) self.deleteById(call.id)
} }
} }
fileprivate func _createGetCall() throws -> ApiCall<T> { /// we want to avoid sending the same GET twice
return try self._createCall(.get, instance: nil) fileprivate func _createGetCallIfNonExistent(_ parameters: [String : String]?) -> ApiCall<T>? {
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] /// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil) throws -> ApiCall<T> { fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil) -> ApiCall<T> {
if let instance { if let instance {
return ApiCall(method: method, data: instance, transactionId: transactionId) return ApiCall(method: method, data: instance, transactionId: transactionId)
} else { } else {
@ -301,61 +309,65 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
self.addOrUpdate(apiCall) 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] /// Sends an insert api call for the provided [instance]
func sendGetRequest(instance: T? = nil, storeId: String? = nil) async throws { fileprivate func _sendGetRequest(parameters: [String : String]?) async throws {
do {
let apiCall = ApiCall<T>(method: .get, data: nil) if let getCall = self._createGetCallIfNonExistent(parameters) {
if let parameteredInstance = instance as? URLParameterConvertible { do {
apiCall.urlParameters = parameteredInstance.queryParameters() try await self._prepareAndSendGetCall(getCall)
} } catch {
if let storeId { self.rescheduleApiCallsIfNecessary()
apiCall.urlParameters = [Services.storeIdURLParameter : storeId] Logger.error(error)
} }
try await self._prepareAndSendGetCall(apiCall) } else {
} catch { self.rescheduleImmediately()
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
} }
} }
func executeBatch(_ batch: OperationBatch<T>) async throws { /// Creates and execute the ApiCalls corresponding to the [batch]
func executeBatch(_ batch: OperationBatch<T>) {
var apiCalls: [ApiCall<T>] = [] var apiCalls: [ApiCall<T>] = []
let transactionId = Store.randomId() let transactionId = Store.randomId()
for insert in batch.inserts { 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) apiCalls.append(call)
} }
for update in batch.updates { 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) apiCalls.append(call)
} }
for delete in batch.deletes { 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) apiCalls.append(call)
} }
self.rescheduleImmediately() self.rescheduleImmediately()
// return try await self._executeApiCalls(apiCalls)
} }
/// Prepares and executes a GET call
fileprivate func _prepareAndSendGetCall(_ apiCall: ApiCall<T>) async throws { fileprivate func _prepareAndSendGetCall(_ apiCall: ApiCall<T>) async throws {
self._prepareCall(apiCall: apiCall) self._prepareCall(apiCall: apiCall)
try await self._executeGetCall(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<V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
// return try await StoreCenter.main.executeGet(apiCall: apiCall)
// }
/// Executes an API call /// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert /// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] { fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] {
// for call in apiCalls {
// Logger.log("execute call = \(call.id)")
// }
let results = try await StoreCenter.main.execute(apiCalls: apiCalls) let results = try await StoreCenter.main.execute(apiCalls: apiCalls)
for result in results { for result in results {
switch result.status { switch result.status {
@ -383,6 +395,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
return self.items.isNotEmpty return self.items.isNotEmpty
} }
/// returns the list of API calls in the collection
func apiCalls() -> [ApiCall<T>] { func apiCalls() -> [ApiCall<T>] {
return self.items return self.items
} }
@ -390,4 +403,24 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
func type() async -> any Storable.Type { return T.self } func type() async -> any Storable.Type { return T.self }
func resourceName() async -> String { return T.resourceName() } func resourceName() async -> String { return T.resourceName() }
// MARK: - Testing
func sendInsertion(_ instance: T) async throws {
let batch = OperationBatch<T>()
batch.addInsert(instance)
self.executeBatch(batch)
}
func sendUpdate(_ instance: T) async throws {
let batch = OperationBatch<T>()
batch.addUpdate(instance)
self.executeBatch(batch)
}
func sendDeletion(_ instance: T) async throws {
let batch = OperationBatch<T>()
batch.addDelete(instance)
self.executeBatch(batch)
}
} }

@ -1,5 +1,5 @@
// //
// StoredCollection.swift // BaseCollection.swift
// LeStorage // LeStorage
// //
// Created by Laurent Morvillier on 02/02/2024. // Created by Laurent Morvillier on 02/02/2024.
@ -7,22 +7,23 @@
import Foundation import Foundation
protocol CollectionHolder { public protocol CollectionHolder {
associatedtype Item associatedtype Item: Storable
var items: [Item] { get } var items: [Item] { get }
func reset() func reset()
} }
protocol SomeCollection: CollectionHolder, Identifiable { public protocol SomeCollection: CollectionHolder, Identifiable {
var resourceName: String { get } var resourceName: String { get }
var hasLoaded: Bool { get } var hasLoaded: Bool { get }
var inMemory: Bool { get } var inMemory: Bool { get }
var type: any Storable.Type { get } var type: any Storable.Type { get }
func allItems() -> [any Storable]
func referenceCount<S: Storable>(type: S.Type, id: String) -> Int func referenceCount<S: Storable>(type: S.Type, id: String) -> Int
func findById(_ id: Item.ID) -> Item?
} }
protocol SomeSyncedCollection: SomeCollection { protocol SomeSyncedCollection: SomeCollection {
@ -30,11 +31,10 @@ protocol SomeSyncedCollection: SomeCollection {
func loadCollectionsFromServerIfNoFile() async throws func loadCollectionsFromServerIfNoFile() async throws
} }
public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollection, CollectionHolder public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
{
/// Doesn't write the collection in a file /// 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 /// The list of stored items
@Published public fileprivate(set) var items: [T] = [] @Published public fileprivate(set) var items: [T] = []
@ -45,6 +45,9 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Provides fast access for instances if the collection has been instanced with [indexed] = true /// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [T.ID: T]? = nil fileprivate var _indexes: [T.ID: T]? = nil
/// A PendingOperationManager instance that manages operations while the collection is not loaded
fileprivate var _pendingOperationManager: PendingOperationManager<T>? = nil
/// Indicates whether the collection has changed, thus requiring a write operation /// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _hasChanged: Bool = false { fileprivate var _hasChanged: Bool = false {
didSet { didSet {
@ -63,6 +66,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Indicates if the collection has loaded locally, with or without a file /// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false fileprivate(set) public var hasLoaded: Bool = false
/// Sets a max number of items inside the collection
fileprivate(set) var limit: Int? = nil fileprivate(set) var limit: Int? = nil
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) { init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) {
@ -73,21 +77,18 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self.store = store self.store = store
self.limit = limit self.limit = limit
self.load() Task(priority: .high) {
} await self.load()
}
fileprivate init() {
// self.synchronized = false
self.store = Store.main
} }
/// Returns a dummy StoredCollection instance init() {
public static func placeholder() -> StoredCollection<T> { self.store = Store.main
return StoredCollection<T>()
} }
/// Returns the name of the managed resource /// Returns the name of the managed resource
var resourceName: String { public var resourceName: String {
return T.resourceName() return T.resourceName()
} }
@ -103,25 +104,27 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
/// Migrates if necessary and asynchronously decodes the json file /// Migrates if necessary and asynchronously decodes the json file
func load() { func load() async {
if !self.inMemory {
do { await self.loadFromFile()
if !self.inMemory { } else {
try self.loadFromFile() await MainActor.run {
self.setAsLoaded()
} }
} catch {
Logger.error(error)
} }
} }
/// Starts the JSON file decoding synchronously or asynchronously /// Starts the JSON file decoding synchronously or asynchronously
func loadFromFile() throws { func loadFromFile() async {
try self._decodeJSONFile() do {
try await self._decodeJSONFile()
} catch {
Logger.error(error)
}
} }
/// Decodes the json file into the items array /// 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) let fileURL = try self.store.fileURL(type: T.self)
@ -130,17 +133,37 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
let decoded: [T] = try jsonString.decodeArray() ?? [] let decoded: [T] = try jsonString.decodeArray() ?? []
self._setItems(decoded) self._setItems(decoded)
} }
self.setAsLoaded() await MainActor.run {
self.setAsLoaded()
}
} }
/// Sets the collection as loaded /// Sets the collection as loaded
/// Send a CollectionDidLoad event /// Send a CollectionDidLoad event
@MainActor
func setAsLoaded() { func setAsLoaded() {
self.hasLoaded = true self.hasLoaded = true
DispatchQueue.main.async { self._mergePendingOperations()
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidLoad, object: self) 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 /// Sets a collection of items and indexes them
@ -201,7 +224,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
/// Deletes an item by its id /// Deletes an item by its id
func deleteById(_ id: T.ID) { public func deleteById(_ id: T.ID) {
if let instance = self.findById(id) { if let instance = self.findById(id) {
self.delete(instance: instance) self.delete(instance: instance)
} }
@ -261,16 +284,28 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
/// Adds an instance to the collection /// 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._affectStoreIdIfNecessary(instance: instance)
self.items.append(instance) self.items.append(instance)
instance.store = self.store instance.store = self.store
self._indexes?[instance.id] = instance self._indexes?[instance.id] = instance
self._applyLimitIfPresent() self._applyLimitIfPresent()
return true
} }
/// Updates an instance to the collection by index /// 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] let item = self.items[index]
if item !== instance { if item !== instance {
@ -279,13 +314,28 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
instance.store = self.store instance.store = self.store
self._indexes?[instance.id] = instance self._indexes?[instance.id] = instance
return true
} }
/// Deletes an instance from the collection /// 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() instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id } self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id) self._indexes?.removeValue(forKey: instance.id)
return true
}
fileprivate func _addPendingOperation(method: StorageMethod, instance: T) {
if self._pendingOperationManager == nil {
self._pendingOperationManager = PendingOperationManager<T>(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 /// If the collection has more instance that its limit, remove the surplus
@ -323,13 +373,6 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self.delete(contentOfs: self.items) self.delete(contentOfs: self.items)
} }
// MARK: - SomeCall
/// Returns the collection items as [any Storable]
func allItems() -> [any Storable] {
return self.items
}
// MARK: - File access // MARK: - File access
/// Schedules a write operation /// Schedules a write operation
@ -355,7 +398,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
/// Simply clears the items of the collection /// Simply clears the items of the collection
func clear() { public func clear() {
self.items.removeAll() self.items.removeAll()
} }
@ -363,14 +406,16 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
public func reset() { public func reset() {
self.items.removeAll() self.items.removeAll()
self.store.removeFile(type: T.self) 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 // MARK: - Reference count
/// Counts the references to an object - given its type and id - inside the collection /// Counts the references to an object - given its type and id - inside the collection
func referenceCount<S: Storable>(type: S.Type, id: String) -> Int { public func referenceCount<S: Storable>(type: S.Type, id: String) -> Int {
let relationships = T.relationships().filter { $0.type == type } let relationships = T.relationships().filter { $0.type == type }
guard relationships.count > 0 else { return 0 } guard relationships.count > 0 else { return 0 }
@ -382,6 +427,15 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
} }
}
public class StoredCollection<T: Storable>: BaseCollection<T>, RandomAccessCollection {
/// Returns a dummy StoredCollection instance
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>()
}
// MARK: - RandomAccessCollection // MARK: - RandomAccessCollection
public var startIndex: Int { return self.items.startIndex } public var startIndex: Int { return self.items.startIndex }
@ -403,3 +457,24 @@ public class StoredCollection<T: Storable>: 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
}
}
}

@ -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<T : Storable>: 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
}
}

@ -0,0 +1,62 @@
//
// PendingOperationManager.swift
// LeStorage
//
// Created by Laurent Morvillier on 01/04/2025.
//
import Foundation
class PendingOperationManager<T: Storable> {
fileprivate(set) var items: [PendingOperation<T>] = []
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<T>] = try jsonString.decode() {
self.items = decoded
}
}
} catch {
Logger.error(error)
}
}
}
func addPendingOperation(method: StorageMethod, instance: T) {
Logger.log("addPendingOperation: \(method), \(instance)")
let operation = PendingOperation<T>(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)
}
}
}
}

@ -46,7 +46,7 @@ final public class Store {
/// The Store singleton /// The Store singleton
public static let main = Store() public static let main = Store()
/// The dictionary of registered StoredCollections /// The dictionary of registered collections
fileprivate var _collections: [String : any SomeCollection] = [:] fileprivate var _collections: [String : any SomeCollection] = [:]
/// The name of the directory to store the json files /// 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 /// - inMemory: Indicates if the collection should only live in memory, and not write into a file
public func registerCollection<T : Storable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection<T> { public func registerCollection<T : Storable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection<T> {
if let collection: StoredCollection<T> = try? self.collection() { if let collection: StoredCollection<T> = try? self.collection() as? StoredCollection<T> {
return collection return collection
} }
@ -97,13 +97,13 @@ final public class Store {
/// - Parameters: /// - Parameters:
/// - indexed: Creates an index to quickly access the data /// - 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 /// - inMemory: Indicates if the collection should only live in memory, and not write into a file
public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection<T> { public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> SyncedCollection<T> {
if let collection: StoredCollection<T> = try? self.collection() { if let collection: SyncedCollection<T> = try? self.syncedCollection() {
return collection return collection
} }
let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit) let collection = SyncedCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit)
self._collections[T.resourceName()] = collection self._collections[T.resourceName()] = collection
StoreCenter.main.loadApiCallCollection(type: T.self) StoreCenter.main.loadApiCallCollection(type: T.self)
return collection return collection
@ -132,7 +132,7 @@ final public class Store {
/// - Parameters: /// - Parameters:
/// - id: the id of the data /// - id: the id of the data
public func findById<T: Storable>(_ id: T.ID) -> T? { public func findById<T: Storable>(_ id: T.ID) -> T? {
guard let collection = self._collections[T.resourceName()] as? StoredCollection<T> else { guard let collection = self._collections[T.resourceName()] as? BaseCollection<T> else {
Logger.w("Collection \(T.resourceName()) not registered") Logger.w("Collection \(T.resourceName()) not registered")
return nil return nil
} }
@ -142,25 +142,33 @@ final public class Store {
/// Filters a collection by predicate /// Filters a collection by predicate
/// - Parameters: /// - Parameters:
/// - isIncluded: a predicate to returns if a data should be filtered in /// - isIncluded: a predicate to returns if a data should be filtered in
public func filter<T: Storable>(isIncluded: (T) throws -> (Bool)) rethrows -> [T] { // public func filter<T: Storable>(isIncluded: (T) throws -> (Bool)) rethrows -> [T] {
do { // do {
return try self.collection().filter(isIncluded) // return try self.collection().filter(isIncluded)
} catch { // } catch {
return [] // return []
// }
// }
/// Returns a collection by type
func syncedCollection<T: SyncedStorable>() throws -> SyncedCollection<T> {
if let collection = self._collections[T.resourceName()] as? SyncedCollection<T> {
return collection
} }
throw StoreError.collectionNotRegistered(type: T.resourceName())
} }
/// Returns a collection by type /// Returns a collection by type
func collection<T: Storable>() throws -> StoredCollection<T> { func collection<T: Storable>() throws -> BaseCollection<T> {
if let collection = self._collections[T.resourceName()] as? StoredCollection<T> { if let collection = self._collections[T.resourceName()] as? BaseCollection<T> {
return collection return collection
} }
throw StoreError.collectionNotRegistered(type: T.resourceName()) throw StoreError.collectionNotRegistered(type: T.resourceName())
} }
func registerOrGetSyncedCollection<T: SyncedStorable>(_ type: T.Type) -> StoredCollection<T> { func registerOrGetSyncedCollection<T: SyncedStorable>(_ type: T.Type) -> SyncedCollection<T> {
do { do {
return try self.collection() return try self.syncedCollection()
} catch { } catch {
return self.registerSynchronizedCollection(indexed: true, inMemory: false) return self.registerSynchronizedCollection(indexed: true, inMemory: false)
} }
@ -204,14 +212,14 @@ final public class Store {
/// Calls addOrUpdateIfNewer from the collection corresponding to the instance /// Calls addOrUpdateIfNewer from the collection corresponding to the instance
func addOrUpdateIfNewer<T: SyncedStorable>(_ instance: T, shared: Bool) { func addOrUpdateIfNewer<T: SyncedStorable>(_ instance: T, shared: Bool) {
let collection: StoredCollection<T> = self.registerOrGetSyncedCollection(T.self) let collection: SyncedCollection<T> = self.registerOrGetSyncedCollection(T.self)
collection.addOrUpdateIfNewer(instance, shared: shared) collection.addOrUpdateIfNewer(instance, shared: shared)
} }
/// Calls deleteById from the collection corresponding to the instance /// Calls deleteById from the collection corresponding to the instance
func deleteNoSync<T: Storable>(instance: T) { func deleteNoSync<T: Storable>(instance: T) {
do { do {
let collection: StoredCollection<T> = try self.collection() let collection: BaseCollection<T> = try self.collection()
collection.delete(instance: instance) collection.delete(instance: instance)
} catch { } catch {
Logger.error(error) Logger.error(error)
@ -220,7 +228,7 @@ final public class Store {
/// Calls deleteById from the collection corresponding to the instance /// Calls deleteById from the collection corresponding to the instance
func deleteNoSync<T: SyncedStorable>(type: T.Type, id: String) throws { func deleteNoSync<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: StoredCollection<T> = try self.collection() let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteByStringIdNoSync(id) collection.deleteByStringIdNoSync(id)
} }
@ -258,8 +266,15 @@ final public class Store {
/// - Parameters: /// - Parameters:
/// - type: a Storable type /// - type: a Storable type
func fileURL<T: Storable>(type: T.Type) throws -> URL { func fileURL<T: Storable>(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() let fileURL = try self._directoryPath()
return fileURL.appending(component: T.fileName()) return fileURL.appending(component: fileName)
} }
/// Removes a file matching a Storable type /// Removes a file matching a Storable type
@ -287,7 +302,7 @@ final public class Store {
func loadCollectionItems<T: SyncedStorable>(_ items: [T]) async { func loadCollectionItems<T: SyncedStorable>(_ items: [T]) async {
do { do {
let collection: StoredCollection<T> = try self.collection() let collection: BaseCollection<T> = try self.collection()
await collection.clearAndLoadItems(items) await collection.clearAndLoadItems(items)
} catch { } catch {
Logger.error(error) Logger.error(error)

@ -35,20 +35,20 @@ public class StoreCenter {
/// The WebSocketManager that manages realtime synchronization /// The WebSocketManager that manages realtime synchronization
fileprivate var _webSocketManager: WebSocketManager? fileprivate var _webSocketManager: WebSocketManager?
/// The dictionary of registered StoredCollections /// The dictionary of registered api collections
fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:] fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:]
/// A collection of DataLog objects, used for the synchronization /// A collection of DataLog objects, used for the synchronization
fileprivate var _dataLogs: StoredCollection<DataLog> fileprivate var _dataLogs: StoredCollection<DataLog>
/// A synchronized collection of DataAccess /// A synchronized collection of DataAccess
fileprivate var _dataAccess: StoredCollection<DataAccess>? = nil fileprivate var _dataAccess: SyncedCollection<DataAccess>? = nil
/// A collection storing FailedAPICall objects /// A collection storing FailedAPICall objects
fileprivate var _failedAPICallsCollection: StoredCollection<FailedAPICall>? = nil fileprivate var _failedAPICallsCollection: SyncedCollection<FailedAPICall>? = nil
/// A collection of Log objects /// A collection of Log objects
fileprivate var _logs: StoredCollection<Log>? = nil fileprivate var _logs: SyncedCollection<Log>? = nil
/// A list of username that cannot synchronize with the server /// A list of username that cannot synchronize with the server
fileprivate var _blackListedUserName: [String] = [] fileprivate var _blackListedUserName: [String] = []
@ -223,6 +223,7 @@ public class StoreCenter {
self._stores.removeAll() self._stores.removeAll()
self._dataAccess?.reset() self._dataAccess?.reset()
self._dataLogs.reset()
self._settingsStorage.update { settings in self._settingsStorage.update { settings in
settings.username = nil settings.username = nil
@ -362,7 +363,7 @@ public class StoreCenter {
/// Resets the ApiCall whose type identifies with the provided collection /// Resets the ApiCall whose type identifies with the provided collection
/// - Parameters: /// - Parameters:
/// - collection: The collection identifying the Storable type /// - collection: The collection identifying the Storable type
public func resetApiCalls<T: SyncedStorable>(collection: StoredCollection<T>) { public func resetApiCalls<T: SyncedStorable>(type: T.Type) {
do { do {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection() let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
Task { Task {
@ -522,8 +523,14 @@ public class StoreCenter {
} }
func sendGetRequest<T: SyncedStorable>(_ type: T.Type, storeId: String?) async throws { func sendGetRequest<T: SyncedStorable>(_ type: T.Type, storeId: String?) async throws {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection() if self.canPerformGet(T.self) {
try await apiCallCollection.sendGetRequest(storeId: storeId) let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
try await apiCallCollection.sendGetRequest(storeId: storeId)
}
}
func canPerformGet<T: SyncedStorable>(_ type: T.Type) -> Bool {
return T.tokenExemptedMethods().contains(where: { $0 == .get }) || self.isAuthenticated
} }
/// Processes Data Access data /// Processes Data Access data
@ -829,7 +836,7 @@ public class StoreCenter {
/// This method triggers the framework to save and send failed api calls /// This method triggers the framework to save and send failed api calls
public func logsFailedAPICalls() { 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 /// 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 /// Updates a local object with a server instance
func updateLocalInstances<T: SyncedStorable>(_ results: [T]) { func updateLocalInstances<T: SyncedStorable>(_ results: [T]) {
for result in results { for result in results {
if let storedCollection: StoredCollection<T> = self.collectionOfInstance(result) { if let syncedCollection: SyncedCollection<T> = self.collectionOfInstance(result) as? SyncedCollection<T> {
if storedCollection.findById(result.id) != nil { if syncedCollection.findById(result.id) != nil {
storedCollection.updateFromServerInstance(result) syncedCollection.updateFromServerInstance(result)
} }
} }
} }
} }
/// Updates a local object with a server instance
// func updateFromServerInstance<T: SyncedStorable>(_ result: T) {
// if let storedCollection: StoredCollection<T> = self.collectionOfInstance(result) {
// if storedCollection.findById(result.id) != nil {
// storedCollection.updateFromServerInstance(result)
// }
// }
// }
/// Returns the collection hosting an instance /// Returns the collection hosting an instance
func collectionOfInstance<T: Storable>(_ instance: T) -> StoredCollection<T>? { func collectionOfInstance<T: Storable>(_ instance: T) -> BaseCollection<T>? {
do { do {
let storedCollection: StoredCollection<T> = try Store.main.collection() let collection: BaseCollection<T> = try Store.main.collection()
if storedCollection.findById(instance.id) != nil { if collection.findById(instance.id) != nil {
return storedCollection return collection
} else { } else {
return self.collectionOfInstanceInSubStores(instance) return self.collectionOfInstanceInSubStores(instance)
} }
@ -950,11 +948,11 @@ public class StoreCenter {
} }
/// Search inside the additional stores to find the collection hosting the instance /// Search inside the additional stores to find the collection hosting the instance
func collectionOfInstanceInSubStores<T: Storable>(_ instance: T) -> StoredCollection<T>? { func collectionOfInstanceInSubStores<T: Storable>(_ instance: T) -> BaseCollection<T>? {
for store in self._stores.values { for store in self._stores.values {
let storedCollection: StoredCollection<T>? = try? store.collection() let collection: BaseCollection<T>? = try? store.collection()
if storedCollection?.findById(instance.id) != nil { if collection?.findById(instance.id) != nil {
return storedCollection return collection
} }
} }
return nil return nil
@ -998,11 +996,11 @@ public class StoreCenter {
// MARK: - Logs // MARK: - Logs
/// Returns the logs collection and instantiates it if necessary /// Returns the logs collection and instantiates it if necessary
fileprivate func _logsCollection() -> StoredCollection<Log> { fileprivate func _logsCollection() -> SyncedCollection<Log> {
if let logs = self._logs { if let logs = self._logs {
return logs return logs
} else { } else {
let logsCollection: StoredCollection<Log> = Store.main.registerCollection(limit: 50) let logsCollection: SyncedCollection<Log> = Store.main.registerSynchronizedCollection(limit: 50)
self._logs = logsCollection self._logs = logsCollection
return logsCollection return logsCollection
} }

@ -8,7 +8,7 @@
import Foundation import Foundation
/// A class extending the capabilities of StoredCollection but supposedly manages only one item /// A class extending the capabilities of StoredCollection but supposedly manages only one item
public class StoredSingleton<T: SyncedStorable>: StoredCollection<T> { public class StoredSingleton<T: SyncedStorable>: SyncedCollection<T> {
/// Sets the singleton to the collection without synchronizing it /// Sets the singleton to the collection without synchronizing it
public func setItemNoSync(_ instance: T) { public func setItemNoSync(_ instance: T) {

@ -1,5 +1,5 @@
// //
// StoredCollection.swift // SyncedCollection.swift
// LeStorage // LeStorage
// //
// Created by Laurent Morvillier on 11/10/2024. // Created by Laurent Morvillier on 11/10/2024.
@ -7,15 +7,20 @@
import Foundation import Foundation
extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSyncedCollection {
/// Returns a dummy SyncedCollection instance
public static func placeholder() -> SyncedCollection<T> {
return SyncedCollection<T>()
}
/// Migrates if necessary and asynchronously decodes the json file /// Migrates if necessary and asynchronously decodes the json file
func load() async { override func load() async {
do { do {
if self.inMemory { if self.inMemory {
try await self.loadDataFromServerIfAllowed() try await self.loadDataFromServerIfAllowed()
} else { } else {
try self.loadFromFile() await self.loadFromFile()
} }
} catch { } catch {
Logger.error(error) Logger.error(error)
@ -41,26 +46,13 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
} }
do { do {
try await StoreCenter.main.sendGetRequest(T.self, storeId: self.storeId) 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 { } catch {
Logger.error(error) Logger.error(error)
} }
} }
/// Updates a local item from a server instance. This method is typically used when the server makes update /// 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 /// - serverInstance: the instance of the object on the server
func updateFromServerInstance(_ serverInstance: T) { func updateFromServerInstance(_ serverInstance: T) {
@ -72,10 +64,6 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
if let localInstance = self.findById(serverInstance.id) { if let localInstance = self.findById(serverInstance.id) {
localInstance.copy(from: serverInstance) localInstance.copy(from: serverInstance)
self.setChanged() 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 // MARK: - Basic operations with sync
/// Adds or update an instance and writes /// Adds or update an instance and writes
public func addOrUpdate(instance: T) { public override func addOrUpdate(instance: T) {
// Logger.log("\(T.resourceName()) : one item")
defer {
self.setChanged()
}
instance.lastUpdate = Date() instance.lastUpdate = Date()
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.updateItem(instance, index: index) if self.updateItem(instance, index: index) {
self._sendUpdate(instance) self._sendUpdate(instance)
self.setChanged()
}
} else { } else {
self.addItem(instance: instance) if self.addItem(instance: instance) {
self._sendInsertion(instance) self._sendInsertion(instance)
self.setChanged()
}
} }
} }
/// Adds or update a sequence and writes /// Adds or update a sequence and writes
public func addOrUpdate(contentOfs sequence: any Sequence<T>) { override public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
// Logger.log("\(T.resourceName()) : \(sequence.underestimatedCount) items") // Logger.log("\(T.resourceName()) : \(sequence.underestimatedCount) items")
defer { defer {
self.setChanged() self.setChanged()
@ -112,13 +100,13 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
for instance in sequence { for instance in sequence {
instance.lastUpdate = date instance.lastUpdate = date
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.updateItem(instance, index: index) if self.updateItem(instance, index: index) {
batch.addUpdate(instance) batch.addUpdate(instance)
// self._sendUpdateIfNecessary(instance) }
} else { // insert } else { // insert
self.addItem(instance: instance) if self.addItem(instance: instance) {
batch.addInsert(instance) batch.addInsert(instance)
// self._sendInsertionIfNecessary(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 /// 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) self.delete(contentOfs: self.items)
} }
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write /// 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<T>) { override public func delete(contentOfs sequence: any RandomAccessCollection<T>) {
defer { defer {
self.setChanged() self.setChanged()
@ -140,19 +128,22 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
guard sequence.isNotEmpty else { return } guard sequence.isNotEmpty else { return }
var deleted: [T] = []
for instance in sequence { for instance in sequence {
// print(">>> SEND DELETE for \(instance.id)") if self.deleteItem(instance) {
self.deleteItem(instance) deleted.append(instance)
}
StoreCenter.main.createDeleteLog(instance) StoreCenter.main.createDeleteLog(instance)
} }
let batch = OperationBatch<T>() let batch = OperationBatch<T>()
batch.deletes = Array(sequence) batch.deletes = deleted
self._sendOperationBatch(batch) self._sendOperationBatch(batch)
} }
/// Deletes an instance and writes /// Deletes an instance and writes
public func delete(instance: T) { override public func delete(instance: T) {
defer { defer {
self.setChanged() self.setChanged()
} }
@ -169,7 +160,7 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
public func deleteDependencies(_ items: any RandomAccessCollection<T>) { public func deleteDependencies(_ items: any RandomAccessCollection<T>) {
guard items.isNotEmpty else { return } 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 // MARK: - Basic operations without sync
@ -221,12 +212,6 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
Task { Task {
do { do {
try await StoreCenter.main.sendOperationBatch(batch) 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 { } catch {
Logger.error(error) Logger.error(error)
} }

@ -8,7 +8,8 @@
import Testing import Testing
@testable import LeStorage @testable import LeStorage
class Thing: ModelObject, Storable { class Thing: SyncedModelObject, SyncedStorable, URLParameterConvertible {
static func resourceName() -> String { return "thing" } static func resourceName() -> String { return "thing" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false } static func filterByStoreIdentifier() -> Bool { return false }
@ -18,7 +19,22 @@ class Thing: ModelObject, Storable {
init(name: String) { init(name: String) {
self.name = name 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 { struct ApiCallTests {
@ -87,4 +103,25 @@ struct ApiCallTests {
await #expect(collection.items.count == 1) await #expect(collection.items.count == 1)
} }
@Test func testGetProvisioning() async throws {
let collection = ApiCallCollection<Thing>()
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)
}
} }

@ -13,32 +13,68 @@ class Car: ModelObject, Storable {
var id: String = Store.randomId() var id: String = Store.randomId()
static func resourceName() -> String { return "car" } 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 { class Boat: ModelObject, SyncedStorable {
var id: String = Store.randomId() var id: String = Store.randomId()
var lastUpdate: Date = Date() var lastUpdate: Date = Date()
var shared: Bool?
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] }
static func resourceName() -> String { return "boat" } static func resourceName() -> String { return "boat" }
static var relationshipNames: [String] = []
var storeId: String? { return nil } var storeId: String? { return nil }
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] { return [] }
} }
struct CollectionsTests { struct CollectionsTests {
var cars: StoredCollection<Car>
var boats: SyncedCollection<Boat>
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 { @Test func differentiationTest() async throws {
let cars: StoredCollection<Car> = Store.main.registerCollection(inMemory: true) try await ensureCollectionLoaded(cars)
let boats: StoredCollection<Boat> = Store.main.registerSynchronizedCollection(inMemory: true) try await ensureCollectionLoaded(boats)
// Cars
#expect(cars.count == 0) #expect(cars.count == 0)
cars.addOrUpdate(instance: Car()) cars.addOrUpdate(instance: Car())
#expect(cars.count == 1) #expect(cars.count == 1)
// Boats
#expect(boats.count == 0) #expect(boats.count == 0)
let oldApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self) let oldApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self)
@ -49,6 +85,7 @@ struct CollectionsTests {
#expect(oldApiCallCount == newApiCallCount - 1) #expect(oldApiCallCount == newApiCallCount - 1)
// Cars and boats
cars.reset() cars.reset()
boats.reset() boats.reset()
#expect(cars.count == 0) #expect(cars.count == 0)

@ -9,9 +9,9 @@ import Testing
import LeStorage import LeStorage
class IntObject: ModelObject, Storable { class IntObject: ModelObject, Storable {
static func resourceName() -> String { "int" } static func resourceName() -> String { "int" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] } static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] }
static var relationshipNames: [String] = []
var id: Int var id: Int
var name: String var name: String
@ -20,6 +20,13 @@ class IntObject: ModelObject, Storable {
self.id = id self.id = id
self.name = name self.name = name
} }
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] {
return []
}
} }
class StringObject: ModelObject, Storable { class StringObject: ModelObject, Storable {
@ -34,15 +41,47 @@ class StringObject: ModelObject, Storable {
self.id = id self.id = id
self.name = name self.name = name
} }
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] {
return []
}
} }
struct IdentifiableTests { struct IdentifiableTests {
let intObjects: StoredCollection<IntObject>
let stringObjects: StoredCollection<StringObject>
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 { @Test func testIntIds() async throws {
let intObjects: StoredCollection<IntObject> = Store.main.registerCollection()
try await ensureCollectionLoaded(self.intObjects)
let int = IntObject(id: 12, name: "test") let int = IntObject(id: 12, name: "test")
intObjects.addOrUpdate(instance: int) self.intObjects.addOrUpdate(instance: int)
if let search = intObjects.findById(12) { if let search = intObjects.findById(12) {
#expect(search.id == 12) #expect(search.id == 12)
@ -52,10 +91,10 @@ struct IdentifiableTests {
} }
@Test func testStringIds() async throws { @Test func testStringIds() async throws {
let stringObjects: StoredCollection<StringObject> = Store.main.registerCollection()
try await ensureCollectionLoaded(self.stringObjects)
let string = StringObject(id: "coco", name: "name") let string = StringObject(id: "coco", name: "name")
stringObjects.addOrUpdate(instance: string) self.stringObjects.addOrUpdate(instance: string)
if let search = stringObjects.findById("coco") { if let search = stringObjects.findById("coco") {
#expect(search.id == "coco") #expect(search.id == "coco")

@ -4,86 +4,120 @@
// //
// Created by Laurent Morvillier on 16/10/2024. // 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
var collection: StoredCollection<MockStorable>! init(_ description: String) {
self.description = description
}
}
struct StoredCollectionTests {
override func setUp() { var collection: StoredCollection<MockStorable>
super.setUp()
self.collection = Store.main.registerCollection() init() {
collection = Store.main.registerCollection()
} }
override func tearDown() { func ensureCollectionLoaded() async throws {
self.collection.clear() // Wait for the collection to finish loading
super.tearDown() // 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() { @Test func testInitialization() async throws {
XCTAssertEqual(collection.items.count, 0) 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") let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item) collection.addOrUpdate(instance: item)
XCTAssertEqual(collection.items.count, 1) #expect(collection.items.count == 1)
XCTAssertEqual(collection.items[0].id, "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") let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item) collection.addOrUpdate(instance: item)
XCTAssertEqual(collection.items.count, 1) #expect(collection.items.count == 1)
try collection.delete(instance: item) collection.delete(instance: item)
XCTAssertEqual(collection.items.count, 0) #expect(collection.items.isEmpty)
} }
func testFindById() throws { @Test func testFindById() async throws {
try await ensureCollectionLoaded()
let item = MockStorable(id: "1", name: "Test") let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item) collection.addOrUpdate(instance: item)
let foundItem = collection.findById("1") if let foundItem = collection.findById("1") {
XCTAssertNotNil(foundItem) #expect(foundItem.id == "1")
XCTAssertEqual(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") let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item) collection.addOrUpdate(instance: item)
try collection.deleteById("1") collection.deleteById("1")
XCTAssertNil(collection.findById("1")) let search = collection.findById("1")
#expect(search == nil)
} }
func testAddOrUpdateMultiple() throws { @Test func testAddOrUpdateMultiple() async throws {
try await ensureCollectionLoaded()
let items = [ let items = [
MockStorable(id: "1", name: "Test1"), MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"), MockStorable(id: "2", name: "Test2"),
] ]
collection.addOrUpdate(contentOfs: items) 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 = [ let items = [
MockStorable(id: "1", name: "Test1"), MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"), MockStorable(id: "2", name: "Test2"),
] ]
collection.addOrUpdate(contentOfs: items) collection.addOrUpdate(contentOfs: items)
XCTAssertEqual(collection.items.count, 2) #expect(collection.items.count == 2)
collection.clear() collection.clear()
XCTAssertEqual(collection.items.count, 0) #expect(collection.items.isEmpty)
} }
func testRandomAccessCollection() { @Test func testRandomAccessCollection() async throws {
try await ensureCollectionLoaded()
let items = [ let items = [
MockStorable(id: "1", name: "Test1"), MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"), MockStorable(id: "2", name: "Test2"),
@ -92,9 +126,15 @@ class StoredCollectionTests: XCTestCase {
collection.addOrUpdate(contentOfs: items) collection.addOrUpdate(contentOfs: items)
XCTAssertEqual(collection.startIndex, 0) #expect(collection.startIndex == 0)
XCTAssertEqual(collection.endIndex, 3) #expect(collection.endIndex == 3)
XCTAssertEqual(collection[1].name, "Test2")
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 { static func resourceName() -> String {
return "mocks" return "mocks"
} }
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] {
return []
}
} }

Loading…
Cancel
Save