Refactor SyncedCollection to own StoredCollection instead of inheriting it

sync3
Laurent 6 months ago
parent 28687133f6
commit 9efc8b14c8
  1. 12
      LeStorage.xcodeproj/project.pbxproj
  2. 1
      LeStorage/ApiCallCollection.swift
  3. 9
      LeStorage/Codables/PendingOperation.swift
  4. 2
      LeStorage/ModelObject.swift
  5. 4
      LeStorage/PendingOperationManager.swift
  6. 2
      LeStorage/Storable.swift
  7. 81
      LeStorage/Store.swift
  8. 36
      LeStorage/StoreCenter.swift
  9. 268
      LeStorage/StoredCollection.swift
  10. 4
      LeStorage/StoredSingleton.swift
  11. 400
      LeStorage/SyncedCollection.swift
  12. 18
      LeStorage/SyncedStorable.swift
  13. 17
      LeStorage/Utils/String+Extensions.swift
  14. 3
      LeStorageTests/IdentifiableTests.swift
  15. 2
      LeStorageTests/StoredCollectionTests.swift

@ -21,9 +21,10 @@
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.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 */; };
C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49774DE2DC4B3D7005CD239 /* SyncData.swift */; }; C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49774DE2DC4B3D7005CD239 /* SyncData.swift */; };
C49779FC2DDB5D89005CD239 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49779FB2DDB5D89005CD239 /* String+Extensions.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 /* BaseCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */; }; C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.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 */; };
@ -79,9 +80,10 @@
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.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>"; };
C49774DE2DC4B3D7005CD239 /* SyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncData.swift; sourceTree = "<group>"; }; C49774DE2DC4B3D7005CD239 /* SyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncData.swift; sourceTree = "<group>"; };
C49779FB2DDB5D89005CD239 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.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 /* BaseCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCollection.swift; sourceTree = "<group>"; }; C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.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>"; };
@ -169,7 +171,7 @@
C425D4572B6D2519002A7B48 /* Store.swift */, C425D4572B6D2519002A7B48 /* Store.swift */,
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */, C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */,
C4A47D642B6E92FE00ADC637 /* Storable.swift */, C4A47D642B6E92FE00ADC637 /* Storable.swift */,
C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */, C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */,
C4D477A02CB9586A0077713D /* SyncedCollection.swift */, C4D477A02CB9586A0077713D /* SyncedCollection.swift */,
C456EFE12BE52379007388E2 /* StoredSingleton.swift */, C456EFE12BE52379007388E2 /* StoredSingleton.swift */,
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */, C4D4779E2CB92FD80077713D /* SyncedStorable.swift */,
@ -198,6 +200,7 @@
C4A47D522B6D2C5F00ADC637 /* Logger.swift */, C4A47D522B6D2C5F00ADC637 /* Logger.swift */,
C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */, C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */,
C4FAE69B2CEB8E9500790446 /* URLManager.swift */, C4FAE69B2CEB8E9500790446 /* URLManager.swift */,
C49779FB2DDB5D89005CD239 /* String+Extensions.swift */,
); );
path = Utils; path = Utils;
sourceTree = "<group>"; sourceTree = "<group>";
@ -363,8 +366,9 @@
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */, C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */,
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */, C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */,
C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */, C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */,
C49779FC2DDB5D89005CD239 /* String+Extensions.swift in Sources */,
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */, C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* BaseCollection.swift in Sources */, C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,
C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */, C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */,
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */, C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */,

@ -442,6 +442,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Returns if the API call collection is not empty /// Returns if the API call collection is not empty
func hasPendingCalls() -> Bool { func hasPendingCalls() -> Bool {
// print("\(T.resourceName()) calls = \(self.items.count)")
return self.items.isNotEmpty return self.items.isNotEmpty
} }

@ -8,7 +8,8 @@
import Foundation import Foundation
enum StorageMethod: String, Codable { enum StorageMethod: String, Codable {
case addOrUpdate case add
case update
case delete case delete
case deleteUnusedShared case deleteUnusedShared
} }
@ -18,12 +19,12 @@ class PendingOperation<T : Storable>: Codable, Equatable {
var id: String = Store.randomId() var id: String = Store.randomId()
var method: StorageMethod var method: StorageMethod
var data: T var data: T
var shouldBeSynchronized: Bool var actionOption: ActionOption
init(method: StorageMethod, data: T, shouldBeSynchronized: Bool) { init(method: StorageMethod, data: T, actionOption: ActionOption) {
self.method = method self.method = method
self.data = data self.data = data
self.shouldBeSynchronized = shouldBeSynchronized self.actionOption = actionOption
} }
static func == (lhs: PendingOperation, rhs: PendingOperation) -> Bool { static func == (lhs: PendingOperation, rhs: PendingOperation) -> Bool {

@ -15,7 +15,7 @@ open class ModelObject: NSObject {
public override init() { } public override init() { }
open func deleteDependencies(store: Store, shouldBeSynchronized: Bool) { open func deleteDependencies(store: Store, actionOption: ActionOption) {
} }

@ -32,10 +32,10 @@ class PendingOperationManager<T: Storable> {
} }
} }
func addPendingOperation(method: StorageMethod, instance: T, shouldBeSynchronized: Bool) { func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) {
Logger.log("addPendingOperation: \(method), \(instance)") Logger.log("addPendingOperation: \(method), \(instance)")
let operation = PendingOperation<T>(method: method, data: instance, shouldBeSynchronized: shouldBeSynchronized) let operation = PendingOperation<T>(method: method, data: instance, actionOption: actionOption)
self.items.append(operation) self.items.append(operation)
self._writeIfNecessary() self._writeIfNecessary()

@ -21,7 +21,7 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// Mimics the behavior of the cascading delete on the django server /// Mimics the behavior of the cascading delete on the django server
/// Typically when we delete a resource, we automatically delete items that depends on it, /// Typically when we delete a resource, we automatically delete items that depends on it,
/// so when we do that on the server, we also need to do it locally /// so when we do that on the server, we also need to do it locally
func deleteDependencies(store: Store, shouldBeSynchronized: Bool) func deleteDependencies(store: Store, actionOption: ActionOption)
/// A method that deletes dependencies of shared resources, but only if they are themselves shared /// A method that deletes dependencies of shared resources, but only if they are themselves shared
/// and not referenced by other objects in the store /// and not referenced by other objects in the store

@ -18,6 +18,7 @@ public enum StoreError: Error, LocalizedError {
case apiCallCollectionNotRegistered(type: String) case apiCallCollectionNotRegistered(type: String)
case synchronizationInactive case synchronizationInactive
case storeNotRegistered(id: String) case storeNotRegistered(id: String)
case castIssue(type: String)
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
@ -39,6 +40,8 @@ public enum StoreError: Error, LocalizedError {
return "The synchronization is not active on this StoreCenter" return "The synchronization is not active on this StoreCenter"
case .storeNotRegistered(let id): case .storeNotRegistered(let id):
return "The store with identifier \(id) is not registered" return "The store with identifier \(id) is not registered"
case .castIssue(let type):
return "Can't cast to \(type)"
} }
} }
@ -51,6 +54,9 @@ final public class Store {
/// The dictionary of registered collections /// The dictionary of registered collections
fileprivate var _collections: [String : any SomeCollection] = [:] fileprivate var _collections: [String : any SomeCollection] = [:]
/// The dictionary of all StoredCollection
fileprivate var _baseCollections: [String : any SomeCollection] = [:]
/// The store identifier, used to name the store directory, and to perform filtering requests to the server /// The store identifier, used to name the store directory, and to perform filtering requests to the server
public fileprivate(set) var identifier: String? = nil public fileprivate(set) var identifier: String? = nil
@ -90,12 +96,14 @@ 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() as? StoredCollection<T> { if let _ = try? self.someCollection(type: T.self) {
return collection fatalError("collection already registered")
// return collection
} }
let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit) let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit)
self._collections[T.resourceName()] = collection self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection return collection
} }
@ -112,6 +120,8 @@ final public class Store {
let collection = SyncedCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit, synchronousLoading: synchronousLoading) let collection = SyncedCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit, synchronousLoading: synchronousLoading)
self._collections[T.resourceName()] = collection self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection.collection
self.storeCenter.loadApiCallCollection(type: T.self) self.storeCenter.loadApiCallCollection(type: T.self)
return collection return collection
} }
@ -119,6 +129,8 @@ final public class Store {
func asyncLoadingSynchronizedCollection<T : SyncedStorable>(inMemory: Bool = false) async -> SyncedCollection<T> { func asyncLoadingSynchronizedCollection<T : SyncedStorable>(inMemory: Bool = false) async -> SyncedCollection<T> {
let collection = await SyncedCollection<T>(store: self, inMemory: inMemory) let collection = await SyncedCollection<T>(store: self, inMemory: inMemory)
self._collections[T.resourceName()] = collection self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection.collection
self.storeCenter.loadApiCallCollection(type: T.self) self.storeCenter.loadApiCallCollection(type: T.self)
return collection return collection
} }
@ -126,6 +138,8 @@ final public class Store {
func asyncLoadingStoredCollection<T : Storable>(inMemory: Bool = false) async -> StoredCollection<T> { func asyncLoadingStoredCollection<T : Storable>(inMemory: Bool = false) async -> StoredCollection<T> {
let collection = await StoredCollection<T>(store: self, inMemory: inMemory) let collection = await StoredCollection<T>(store: self, inMemory: inMemory)
self._collections[T.resourceName()] = collection self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection return collection
} }
@ -153,7 +167,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? BaseCollection<T> else { guard let collection = self._baseCollections[T.resourceName()] as? StoredCollection<T> else {
Logger.w("Collection \(T.resourceName()) not registered") Logger.w("Collection \(T.resourceName()) not registered")
return nil return nil
} }
@ -174,13 +188,21 @@ final public class Store {
} }
/// Returns a collection by type /// Returns a collection by type
func collection<T: Storable>() throws -> BaseCollection<T> { func someCollection<T: Storable>(type: T.Type) throws -> any SomeCollection {
if let collection = self._collections[T.resourceName()] as? BaseCollection<T> { if let collection = self._collections[T.resourceName()] {
return collection return collection
} }
throw StoreError.collectionNotRegistered(type: T.resourceName()) throw StoreError.collectionNotRegistered(type: T.resourceName())
} }
/// Returns a collection by type
// func collection<T: Storable>() throws -> BaseCollection<T> {
// if let collection = self._collections[T.resourceName()] as? BaseCollection<T> {
// return collection
// }
// throw StoreError.collectionNotRegistered(type: T.resourceName())
// }
func registerOrGetSyncedCollection<T: SyncedStorable>(_ type: T.Type) -> SyncedCollection<T> { func registerOrGetSyncedCollection<T: SyncedStorable>(_ type: T.Type) -> SyncedCollection<T> {
do { do {
return try self.syncedCollection() return try self.syncedCollection()
@ -236,19 +258,19 @@ 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: Storable>(instance: T) { // func deleteNoSync<T: Storable>(instance: T) {
do { // do {
let collection: BaseCollection<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)
} // }
} // }
/// 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 deleteNoSyncNoCascade<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: SyncedCollection<T> = try self.syncedCollection() let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteByStringIdNoSync(id) collection.deleteNoSyncNoCascade(id: id)
} }
/// Calls deleteById from the collection corresponding to the instance /// Calls deleteById from the collection corresponding to the instance
@ -273,7 +295,7 @@ final public class Store {
public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type, shouldBeSynchronized: Bool, _ handler: (T) throws -> Bool) { public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type, shouldBeSynchronized: Bool, _ handler: (T) throws -> Bool) {
do { do {
let collection: BaseCollection<T> = try self.collection() let collection: SyncedCollection<T> = try self.syncedCollection()
let items = try collection.items.filter(handler) let items = try collection.items.filter(handler)
self.deleteUnusedSharedDependencies(items) self.deleteUnusedSharedDependencies(items)
} catch { } catch {
@ -300,36 +322,25 @@ final public class Store {
} }
} }
public func deleteAllDependencies<T: Storable>(type: T.Type, shouldBeSynchronized: Bool) { public func deleteAllDependencies<T: Storable>(type: T.Type, actionOption: ActionOption) {
do {
let collection: BaseCollection<T> = try self.collection()
try self._deleteDependencies(Array(collection.items), shouldBeSynchronized: shouldBeSynchronized)
} catch {
Logger.error(error)
}
}
public func deleteDependencies<T: Storable>(type: T.Type, shouldBeSynchronized: Bool, _ handler: (T) throws -> Bool) {
do { do {
let collection: BaseCollection<T> = try self.collection() let collection = try self.someCollection(type: type)
let items = try collection.items.filter(handler) collection.deleteAllItemsAndDependencies(actionOption: actionOption)
try self._deleteDependencies(items, shouldBeSynchronized: shouldBeSynchronized)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
fileprivate func _deleteDependencies<T: Storable>(_ items: [T], shouldBeSynchronized: Bool) throws { public func deleteDependencies<T: Storable>(type: T.Type, actionOption: ActionOption, _ isIncluded: (any Storable) -> Bool) {
do { do {
let collection: BaseCollection<T> = try self.collection() let collection: any SomeCollection = try self.someCollection(type: type)
for item in items { collection.deleteDependencies(actionOption: actionOption, isIncluded)
item.deleteDependencies(store: self, shouldBeSynchronized: shouldBeSynchronized)
}
collection.deleteDependencies(items)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
// MARK: - Write // MARK: - Write

@ -765,7 +765,7 @@ public class StoreCenter {
@MainActor @MainActor
func synchronizationDelete<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) { func synchronizationDelete<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) {
do { do {
try self._store(id: storeId).deleteNoSync(type: type, id: id) try self._store(id: storeId).deleteNoSyncNoCascade(type: type, id: id)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
@ -780,7 +780,7 @@ public class StoreCenter {
if self._instanceShared(id: id, type: type) { if self._instanceShared(id: id, type: type) {
let count = self.mainStore.referenceCount(type: type, id: id) let count = self.mainStore.referenceCount(type: type, id: id)
if count == 0 { if count == 0 {
try self._store(id: storeId).deleteNoSync(type: type, id: id) try self._store(id: storeId).deleteNoSyncNoCascade(type: type, id: id)
} }
} }
} catch { } catch {
@ -994,29 +994,31 @@ public class StoreCenter {
} }
/// Returns the collection hosting an instance /// Returns the collection hosting an instance
func collectionOfInstance<T: Storable>(_ instance: T) -> BaseCollection<T>? { func collectionOfInstance<T: Storable>(_ instance: T) -> (any SomeCollection)? {
do { do {
let collection: BaseCollection<T> = try self.mainStore.collection() if let storeId = instance.getStoreId() {
if collection.findById(instance.id) != nil { let store = try self.store(identifier: storeId)
return collection return try store.someCollection(type: T.self)
} else { } else {
return self.collectionOfInstanceInSubStores(instance) return try Store.main.someCollection(type: T.self)
} }
} catch { } catch {
return self.collectionOfInstanceInSubStores(instance) Logger.error(error)
} }
return nil
} }
/// 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) -> BaseCollection<T>? { // func collectionOfInstanceInSubStores<T: Storable>(_ instance: T) -> BaseCollection<T>? {
for store in self._stores.values { // for store in self._stores.values {
let collection: BaseCollection<T>? = try? store.collection() // let collection: BaseCollection<T>? = try? store.collection()
if collection?.findById(instance.id) != nil { // if collection?.findById(instance.id) != nil {
return collection // return collection
} // }
} // }
return nil // return nil
} // }
// MARK: - Data Access // MARK: - Data Access

@ -1,5 +1,5 @@
// //
// BaseCollection.swift // StoredCollection.swift
// LeStorage // LeStorage
// //
// Created by Laurent Morvillier on 02/02/2024. // Created by Laurent Morvillier on 02/02/2024.
@ -8,27 +8,58 @@
import Foundation import Foundation
import Combine import Combine
public protocol CollectionHolder { public protocol SomeCollection<Item>: Identifiable {
associatedtype Item: Storable
var items: [Item] { get } associatedtype Item: Storable
func reset()
}
public protocol SomeCollection: CollectionHolder, Identifiable {
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 reset()
func referenceCount<S: Storable>(type: S.Type, id: String) -> Int func referenceCount<S: Storable>(type: S.Type, id: String) -> Int
var items: [Item] { get }
func deleteAllItemsAndDependencies(actionOption: ActionOption)
func deleteDependencies(actionOption: ActionOption, _ isIncluded: (Item) -> Bool)
func findById(_ id: Item.ID) -> Item? func findById(_ id: Item.ID) -> Item?
}
protocol CollectionDelegate<Item> {
associatedtype Item: Storable
func loadingForMemoryCollection() async
func itemMerged(_ pendingOperation: PendingOperation<Item>)
} }
public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder { enum CollectionMethod {
case insert
case update
case delete
}
public struct ActionResult<T> {
var instance: T
var method: CollectionMethod
var pending: Bool
}
public struct ActionOption: Codable {
var synchronize: Bool
var cascade: Bool
static let standard: ActionOption = ActionOption(synchronize: false, cascade: false)
static let cascade: ActionOption = ActionOption(synchronize: false, cascade: true)
static let syncedCascade: ActionOption = ActionOption(synchronize: true, cascade: true)
}
public class StoredCollection<T: Storable>: SomeCollection {
public typealias Item = T
/// Doesn't write the collection in a file /// Doesn't write the collection in a file
fileprivate(set) public var inMemory: Bool = false fileprivate(set) public var inMemory: Bool = false
@ -47,7 +78,7 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
/// Indicates whether the collection has changed, thus requiring a write operation /// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _triggerWrite: Bool = false { fileprivate var _triggerWrite: Bool = false {
didSet { didSet {
if self._triggerWrite == true { if self._triggerWrite == true && self.inMemory == false {
self._scheduleWrite() self._scheduleWrite()
DispatchQueue.main.async { DispatchQueue.main.async {
@ -65,6 +96,8 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
/// Sets a max number of items inside the collection /// Sets a max number of items inside the collection
fileprivate(set) var limit: Int? = nil fileprivate(set) var limit: Int? = nil
fileprivate var _delegate: (any CollectionDelegate<T>)? = nil
init(store: Store, inMemory: Bool = false) async { init(store: Store, inMemory: Bool = false) async {
self.store = store self.store = store
if self.inMemory == false { if self.inMemory == false {
@ -72,13 +105,14 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
} }
} }
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false) { init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false, delegate: (any CollectionDelegate<T>)? = nil) {
if indexed { if indexed {
self._indexes = [:] self._indexes = [:]
} }
self.inMemory = inMemory self.inMemory = inMemory
self.store = store self.store = store
self.limit = limit self.limit = limit
self._delegate = delegate
if synchronousLoading { if synchronousLoading {
Task { Task {
@ -109,7 +143,7 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
// MARK: - Loading // MARK: - Loading
/// Sets the collection as changed to trigger a write /// Sets the collection as changed to trigger a write
func triggerWrite() { fileprivate func requestWrite() {
self._triggerWrite = true self._triggerWrite = true
} }
@ -118,6 +152,7 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
if !self.inMemory { if !self.inMemory {
await self.loadFromFile() await self.loadFromFile()
} else { } else {
await self._delegate?.loadingForMemoryCollection()
await MainActor.run { await MainActor.run {
self.setAsLoaded() self.setAsLoaded()
} }
@ -168,6 +203,19 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
self.items = items self.items = items
self._updateIndexIfNecessary() self._updateIndexIfNecessary()
} }
@MainActor
func loadAndWrite(_ items: [T], clear: Bool = false) {
if clear {
self.setItems(items)
self.setAsLoaded()
} else {
self.setAsLoaded()
self.addOrUpdate(contentOfs: items)
}
self.requestWrite()
}
/// Updates the whole index with the items array /// Updates the whole index with the items array
fileprivate func _updateIndexIfNecessary() { fileprivate func _updateIndexIfNecessary() {
@ -180,74 +228,69 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
/// Adds or updates the provided instance inside the collection /// Adds or updates the provided instance inside the collection
/// Adds it if its id is not found, and otherwise updates it /// Adds it if its id is not found, and otherwise updates it
public func addOrUpdate(instance: T) { @discardableResult public func addOrUpdate(instance: T) -> ActionResult<T> {
self.addOrUpdateItem(instance: instance) defer {
self.requestWrite()
}
return self._rawAddOrUpdate(instance: instance)
} }
/// Adds or update an instance inside the collection and writes /// Adds or update a sequence of elements
func addOrUpdateItem(instance: T) { public func addOrUpdate(contentOfs sequence: any Sequence<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
defer { defer {
self._triggerWrite = true self.requestWrite()
}
for instance in sequence {
let result = self._rawAddOrUpdate(instance: instance)
handler?(result)
} }
}
fileprivate func _rawAddOrUpdate(instance: T) -> ActionResult<T> {
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) let updated = self.updateItem(instance, index: index, actionOption: .standard)
return ActionResult(instance: instance, method: .update, pending: !updated)
} else { } else {
self.addItem(instance: instance) let added = self.addItem(instance: instance)
return ActionResult(instance: instance, method: .insert, pending: !added)
} }
} }
/// A method the treat the collection as a single instance holder /// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) { func setSingletonNoSync(instance: T) {
defer { defer {
self._triggerWrite = true self.requestWrite()
} }
self.items.removeAll() self.items.removeAll()
self.addItem(instance: instance) self.addItem(instance: instance)
} }
/// Deletes the instance in the collection and sets the collection as changed to trigger a write /// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T) { public func delete(instance: T, actionOption: ActionOption) {
defer { defer {
self._triggerWrite = true self._triggerWrite = true
} }
self.deleteItem(instance) self.deleteItem(instance, actionOption: actionOption)
} }
/// 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>) { public func delete(contentOfs sequence: any RandomAccessCollection<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
self.delete(contentOfs: sequence, actionOption: .cascade, handler: handler)
defer {
self._triggerWrite = true
}
for instance in sequence {
self.deleteItem(instance)
}
}
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
self.addSequence(sequence)
// self._addOrUpdate(contentOfs: sequence)
} }
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption, handler: ((ActionResult<T>) -> ())? = nil) {
/// Adds a sequence of objects inside the collection and performs a write
func addSequence(_ sequence: any Sequence<T>) {
defer { defer {
self._triggerWrite = true self._triggerWrite = true
} }
for instance in sequence { for instance in sequence {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { let deleted = self.deleteItem(instance, actionOption: actionOption)
self.updateItem(instance, index: index) handler?(ActionResult(instance: instance, method: .delete, pending: !deleted))
} else { // insert
self.addItem(instance: instance)
}
} }
} }
/// This method sets the storeId for the given instance if the collection belongs to a store with an id /// This method sets the storeId for the given instance if the collection belongs to a store with an id
@ -261,11 +304,16 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
} }
} }
func add(instance: T, actionOption: ActionOption) {
self.addItem(instance: instance, actionOption: actionOption)
self.requestWrite()
}
/// Adds an instance to the collection /// Adds an instance to the collection
@discardableResult func addItem(instance: T, shouldBeSynchronized: Bool = false) -> Bool { @discardableResult fileprivate func addItem(instance: T, actionOption: ActionOption = .standard) -> Bool {
if !self.hasLoaded { if !self.hasLoaded {
self.addPendingOperation(method: .addOrUpdate, instance: instance, shouldBeSynchronized: shouldBeSynchronized) self.addPendingOperation(method: .add, instance: instance, actionOption: actionOption)
return false return false
} }
@ -277,11 +325,16 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
return true return true
} }
func update(_ instance: T, index: Int, actionOption: ActionOption) {
self.updateItem(instance, index: index, actionOption: actionOption)
self.requestWrite()
}
/// Updates an instance to the collection by index /// Updates an instance to the collection by index
@discardableResult func updateItem(_ instance: T, index: Int, shouldBeSynchronized: Bool = false) -> Bool { @discardableResult fileprivate func updateItem(_ instance: T, index: Int, actionOption: ActionOption) -> Bool {
if !self.hasLoaded { if !self.hasLoaded {
self.addPendingOperation(method: .addOrUpdate, instance: instance, shouldBeSynchronized: shouldBeSynchronized) self.addPendingOperation(method: .update, instance: instance, actionOption: actionOption)
return false return false
} }
@ -296,15 +349,15 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
} }
/// Deletes an instance from the collection /// Deletes an instance from the collection
@discardableResult func deleteItem(_ instance: T, shouldBeSynchronized: Bool = false) -> Bool { @discardableResult fileprivate func deleteItem(_ instance: T, actionOption: ActionOption = .cascade) -> Bool {
if !self.hasLoaded { if !self.hasLoaded {
self.addPendingOperation(method: .delete, instance: instance, shouldBeSynchronized: shouldBeSynchronized) self.addPendingOperation(method: .delete, instance: instance, actionOption: actionOption)
return false return false
} }
if shouldBeSynchronized { // when user initiated, we want to cascade delete, but when synchronized, we want the delete notifications to make the job and be sure everything works if actionOption.cascade { // when user initiated, we want to cascade delete, but when synchronized, we want the delete notifications to make the job and be sure everything works
instance.deleteDependencies(store: self.store, shouldBeSynchronized: shouldBeSynchronized) instance.deleteDependencies(store: self.store, actionOption: actionOption)
} }
self.localDeleteOnly(instance: instance) self.localDeleteOnly(instance: instance)
@ -312,10 +365,10 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
} }
/// Deletes an instance from the collection /// Deletes an instance from the collection
@discardableResult func deleteUnusedShared(_ instance: T, shouldBeSynchronized: Bool = false) -> Bool { @discardableResult func deleteUnusedShared(_ instance: T, actionOption: ActionOption) -> Bool {
if !self.hasLoaded { if !self.hasLoaded {
self.addPendingOperation(method: .deleteUnusedShared, instance: instance, shouldBeSynchronized: shouldBeSynchronized) self.addPendingOperation(method: .deleteUnusedShared, instance: instance, actionOption: actionOption)
return false return false
} }
@ -339,6 +392,13 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
} }
} }
func deleteByStringId(_ id: String, actionOption: ActionOption = .cascade) {
let realId = T.buildRealId(id: id)
if let instance = self.findById(realId) {
self.deleteItem(instance, actionOption: actionOption)
}
}
/// Returns the instance corresponding to the provided [id] /// Returns the instance corresponding to the provided [id]
public func findById(_ id: T.ID) -> T? { public func findById(_ id: T.ID) -> T? {
if let index = self._indexes, let instance = index[id] { if let index = self._indexes, let instance = index[id] {
@ -361,17 +421,33 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
} }
} }
public func deleteAllItemsAndDependencies(actionOption: ActionOption) {
self._delete(contentOfs: self.items, actionOption: actionOption)
}
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) {
let items = self.items.filter(isIncluded)
self._delete(contentOfs: items, actionOption: actionOption)
}
fileprivate func _delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) {
for instance in sequence {
self.deleteItem(instance, actionOption: actionOption)
}
}
// MARK: - Pending operations // MARK: - Pending operations
func addPendingOperation(method: StorageMethod, instance: T, shouldBeSynchronized: Bool) { func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) {
if self.pendingOperationManager == nil { if self.pendingOperationManager == nil {
self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory) self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory)
} }
self._addPendingOperationIfPossible(method: method, instance: instance, shouldBeSynchronized: false) self._addPendingOperationIfPossible(method: method, instance: instance, actionOption: actionOption)
} }
fileprivate func _addPendingOperationIfPossible(method: StorageMethod, instance: T, shouldBeSynchronized: Bool) { fileprivate func _addPendingOperationIfPossible(method: StorageMethod, instance: T, actionOption: ActionOption) {
self.pendingOperationManager?.addPendingOperation(method: method, instance: instance, shouldBeSynchronized: shouldBeSynchronized) self.pendingOperationManager?.addPendingOperation(method: method, instance: instance, actionOption: actionOption)
} }
fileprivate func _mergePendingOperations() { fileprivate func _mergePendingOperations() {
@ -381,18 +457,16 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
Logger.log(">>> Merge pending: \(manager.items.count)") Logger.log(">>> Merge pending: \(manager.items.count)")
for item in manager.items { for item in manager.items {
let data = item.data let data = item.data
switch (item.method, item.shouldBeSynchronized) { switch item.method {
case (.addOrUpdate, true): case .add, .update:
self.addOrUpdate(instance: data) self.addOrUpdate(instance: data)
case (.addOrUpdate, false): case .delete:
self.addOrUpdateItem(instance: data) self.deleteItem(data, actionOption: item.actionOption)
case (.delete, true): case .deleteUnusedShared:
self.delete(instance: data) self.deleteUnusedShared(data, actionOption: item.actionOption)
case (.delete, false):
self.deleteItem(data)
case (.deleteUnusedShared, _):
self.deleteUnusedShared(data)
} }
self._delegate?.itemMerged(item)
} }
self.pendingOperationManager = nil self.pendingOperationManager = nil
@ -402,10 +476,7 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
/// Schedules a write operation /// Schedules a write operation
fileprivate func _scheduleWrite() { fileprivate func _scheduleWrite() {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait {
guard !self.inMemory else { return }
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time
self._write() self._write()
} }
} }
@ -431,7 +502,6 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
public func reset() { public func reset() {
self.items.removeAll() self.items.removeAll()
self.store.removeFile(type: T.self) self.store.removeFile(type: T.self)
triggerWrite()
} }
public var type: any Storable.Type { return T.self } public var type: any Storable.Type { return T.self }
@ -450,48 +520,33 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
}.count }.count
} }
} }
// MARK: - for Synced Collection
@MainActor
func updateLocalInstance(_ serverInstance: T) {
if let localInstance = self.findById(serverInstance.id) {
localInstance.copy(from: serverInstance)
self.requestWrite()
}
}
} }
public class StoredCollection<T: Storable>: BaseCollection<T>, RandomAccessCollection { extension StoredCollection: RandomAccessCollection {
/// Returns a dummy StoredCollection instance
public static func placeholder() -> StoredCollection<T> { public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>(store: Store(storeCenter: StoreCenter.main)) return StoredCollection<T>(store: Store(storeCenter: StoreCenter.main))
} }
// MARK: - 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)
}
open subscript(index: Int) -> T {
get {
return self.items[index]
}
set(newValue) {
self.items[index] = newValue
self._triggerWrite = true
}
}
}
extension SyncedCollection: RandomAccessCollection {
public var startIndex: Int { return self.items.startIndex } public var startIndex: Int { return self.items.startIndex }
public var endIndex: Int { return self.items.endIndex } public var endIndex: Int { return self.items.endIndex }
public func index(after i: Int) -> Int { public func index(after i: Int) -> Int {
return self.items.index(after: i) return self.items.index(after: i)
} }
public subscript(index: Int) -> T { public subscript(index: Int) -> T {
get { get {
return self.items[index] return self.items[index]
@ -501,4 +556,5 @@ extension SyncedCollection: RandomAccessCollection {
self._triggerWrite = true self._triggerWrite = true
} }
} }
} }

@ -25,7 +25,7 @@ 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) {
self.setSingletonNoSync(instance: instance) self.collection.setSingletonNoSync(instance: instance)
} }
/// updates the existing singleton /// updates the existing singleton
@ -37,7 +37,7 @@ public class StoredSingleton<T: SyncedStorable>: SyncedCollection<T> {
/// Returns the singleton /// Returns the singleton
public func item() -> T? { public func item() -> T? {
return self.items.first return self.collection.items.first
} }
public func tryPutBeforeUpdating(_ instance: T) async throws { public func tryPutBeforeUpdating(_ instance: T) async throws {

@ -12,25 +12,48 @@ protocol SomeSyncedCollection: SomeCollection {
func loadCollectionsFromServerIfNoFile() async throws func loadCollectionsFromServerIfNoFile() async throws
} }
public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSyncedCollection { public class SyncedCollection<T : SyncedStorable>: SomeSyncedCollection, CollectionDelegate {
public typealias Item = T
let store: Store
let collection: StoredCollection<T>
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false) {
self.store = store
self.collection = StoredCollection<T>(store: store, indexed: indexed, limit: limit, synchronousLoading: synchronousLoading)
}
init(store: Store, inMemory: Bool) async {
self.store = store
self.collection = await StoredCollection(store: store, inMemory: inMemory)
}
var storeCenter: StoreCenter { return self.store.storeCenter }
public var storeId: String? {
return self.store.identifier
}
/// Returns a dummy SyncedCollection instance /// Returns a dummy SyncedCollection instance
public static func placeholder() -> SyncedCollection<T> { public static func placeholder() -> SyncedCollection<T> {
return SyncedCollection<T>(store: Store(storeCenter: StoreCenter.main)) return SyncedCollection<T>(store: Store(storeCenter: StoreCenter.main))
} }
/// Migrates if necessary and asynchronously decodes the json file /// Migrates if necessary and asynchronously decodes the json file
override 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 {
await self.loadFromFile() // await self.loadFromFile()
} // }
} catch { // } catch {
Logger.error(error) // Logger.error(error)
} // }
} // }
/// Loads the collection using the server data only if the collection file doesn't exists /// Loads the collection using the server data only if the collection file doesn't exists
func loadCollectionsFromServerIfNoFile() async throws { func loadCollectionsFromServerIfNoFile() async throws {
@ -64,90 +87,56 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
} }
Task { Task {
await _updateLocalInstance(serverInstance) await self.collection.updateLocalInstance(serverInstance)
} }
// DispatchQueue.main.async {
// if let localInstance = self.findById(serverInstance.id) {
// localInstance.copy(from: serverInstance)
// self.setChanged()
// }
// }
}
@MainActor
fileprivate func _updateLocalInstance(_ serverInstance: T) {
if let localInstance = self.findById(serverInstance.id) {
localInstance.copy(from: serverInstance)
self.triggerWrite()
}
} }
@MainActor @MainActor
func loadItems(_ items: [T], clear: Bool = false) { func loadItems(_ items: [T], clear: Bool = false) {
if clear { self.collection.loadAndWrite(items, clear: clear)
self.setItems(items)
self.setAsLoaded()
} else {
self.setAsLoaded()
self.addOrUpdateNoSync(contentOfs: items)
}
self.triggerWrite()
} }
// MARK: - Basic operations with sync // MARK: - Basic operations with sync
/// Adds or update an instance synchronously, dispatching network operations to background tasks /// Adds or update an instance synchronously, dispatching network operations to background tasks
public override func addOrUpdate(instance: T) { public func addOrUpdate(instance: T) {
if let result = _addOrUpdateCore(instance: instance) { let result = _addOrUpdateCore(instance: instance)
if result.isNewItem { if result.method == .insert {
Task { await self._sendInsertion(result.item) } Task { await self._sendInsertion(instance) }
} else { } else {
Task { await self._sendUpdate(result.item) } Task { await self._sendUpdate(instance) }
}
} }
} }
/// Private helper function that contains the shared logic /// Private helper function that contains the shared logic
private func _addOrUpdateCore(instance: T) -> (item: T, isNewItem: Bool)? { private func _addOrUpdateCore(instance: T) -> ActionResult<T> {
instance.lastUpdate = Date() instance.lastUpdate = Date()
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
if self.updateItem(instance, index: index, shouldBeSynchronized: true) { let result = self.collection.addOrUpdate(instance: instance)
self.triggerWrite() if result.method == .update {
if instance.shared == true { if instance.shared == true {
self._cleanUpSharedDependencies() self._cleanUpSharedDependencies()
}
return (instance, false)
}
} else {
if self.addItem(instance: instance, shouldBeSynchronized: true) {
self.triggerWrite()
return (instance, true)
} }
} }
return nil
return result
} }
fileprivate func _addOrUpdateCore(contentOfs sequence: any Sequence<T>) -> OperationBatch<T> { fileprivate func _addOrUpdateCore(contentOfs sequence: any Sequence<T>) -> OperationBatch<T> {
defer {
self.triggerWrite()
}
let date = Date() let date = Date()
let batch = OperationBatch<T>() let batch = OperationBatch<T>()
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 }) { let result = self.collection.addOrUpdate(instance: instance)
if self.updateItem(instance, index: index, shouldBeSynchronized: true) {
batch.addUpdate(instance) if result.method == .insert {
} batch.addInsert(instance)
} else { // insert } else {
if self.addItem(instance: instance, shouldBeSynchronized: true) { batch.addUpdate(instance)
batch.addInsert(instance)
}
} }
} }
@ -158,48 +147,47 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
} }
/// Adds or update a sequence and writes /// Adds or update a sequence and writes
override public func addOrUpdate(contentOfs sequence: any Sequence<T>) { public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
let batch = self._addOrUpdateCore(contentOfs: sequence) let batch = self._addOrUpdateCore(contentOfs: sequence)
Task { await self._sendOperationBatch(batch) } Task { await self._sendOperationBatch(batch) }
} }
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write /// Deletes an instance and writes
fileprivate func _deleteCore(contentOfs sequence: any RandomAccessCollection<T>) -> OperationBatch<T> { public func delete(instance: T) {
defer {
self.triggerWrite()
}
var deleted: [T] = []
for instance in sequence { self.collection.delete(instance: instance, actionOption: .syncedCascade)
if self.deleteItem(instance, shouldBeSynchronized: true) { self.storeCenter.createDeleteLog(instance)
deleted.append(instance) Task { await self._sendDeletion(instance) }
}
self.storeCenter.createDeleteLog(instance)
}
let batch = OperationBatch<T>()
batch.deletes = deleted
return batch
} }
/// 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 override func delete(contentOfs sequence: any RandomAccessCollection<T>) { public func delete(contentOfs sequence: any RandomAccessCollection<T>) {
guard sequence.isNotEmpty else { return } self.delete(contentOfs: sequence, actionOption: .syncedCascade)
let batch = self._deleteCore(contentOfs: sequence)
Task { await self._sendOperationBatch(batch) }
} }
/// Deletes an instance and writes func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) {
override public func delete(instance: T) { guard sequence.isNotEmpty else { return }
defer { let batch = self._deleteCore(contentOfs: sequence, actionOption: actionOption)
self.triggerWrite() if actionOption.synchronize {
Task { await self._sendOperationBatch(batch) }
} }
self.deleteItem(instance, shouldBeSynchronized: true) }
self.storeCenter.createDeleteLog(instance)
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write
fileprivate func _deleteCore(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) -> OperationBatch<T> {
Task { await self._sendDeletion(instance) } var deleted: [T] = []
self.collection.delete(contentOfs: sequence, actionOption: actionOption) { result in
self.storeCenter.createDeleteLog(result.instance)
if !result.pending {
deleted.append(result.instance)
}
}
let batch = OperationBatch<T>()
batch.deletes = deleted
return batch
} }
/// Deletes an instance without writing, logs the operation and sends an API call /// Deletes an instance without writing, logs the operation and sends an API call
@ -209,30 +197,33 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
//// await self._sendDeletion(instance) //// await self._sendDeletion(instance)
// } // }
public func deleteDependencies(_ items: any RandomAccessCollection<T>, shouldBeSynchronized: Bool) { // public func deleteDependencies(_ items: any RandomAccessCollection<T>, actionOption: ActionOption) {
guard items.isNotEmpty else { return } // guard items.isNotEmpty else { return }
if shouldBeSynchronized { // if actionOption.synchronize {
self.delete(contentOfs: items) // self.delete(contentOfs: items)
} else { // } else {
self.deleteNoSync(contentOfs: items) // self.deleteNoSync(contentOfs: items)
} // }
} // }
public override func deleteDependencies(_ items: any Sequence<T>) { // public func deleteDependencies(_ items: any Sequence<T>) {
super.deleteDependencies(items) //
// self.collection.deleteDependencies(items)
let batch = OperationBatch<T>() //
batch.deletes = Array(items) //// super.deleteDependencies(items)
Task { await self._sendOperationBatch(batch) } //
} // let batch = OperationBatch<T>()
// batch.deletes = Array(items)
// Task { await self._sendOperationBatch(batch) }
// }
public func deleteDependenciesAsync(_ items: any Sequence<T>) async { // public func deleteDependenciesAsync(_ items: any Sequence<T>) async {
super.deleteDependencies(items) // super.deleteDependencies(items)
//
let batch = OperationBatch<T>() // let batch = OperationBatch<T>()
batch.deletes = Array(items) // batch.deletes = Array(items)
await self._sendOperationBatch(batch) // await self._sendOperationBatch(batch)
} // }
fileprivate func _cleanUpSharedDependencies() { fileprivate func _cleanUpSharedDependencies() {
for relationship in T.relationships() { for relationship in T.relationships() {
@ -261,22 +252,38 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
fileprivate func _deleteUnusedSharedInstances() { fileprivate func _deleteUnusedSharedInstances() {
let sharedItems = self.items.filter { $0.shared == true } let sharedItems = self.collection.items.filter { $0.shared == true }
for sharedItem in sharedItems { for sharedItem in sharedItems {
self.store.deleteUnusedSharedIfNecessary(sharedItem) self.store.deleteUnusedSharedIfNecessary(sharedItem)
} }
} }
public func deleteAllItemsAndDependencies(actionOption: ActionOption) {
if actionOption.synchronize {
self.delete(contentOfs: self.items, actionOption: actionOption)
} else {
self.collection.deleteAllItemsAndDependencies(actionOption: actionOption)
}
}
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) {
let items = self.items.filter(isIncluded)
if actionOption.synchronize {
self.delete(contentOfs: items, actionOption: actionOption)
} else {
self.collection.delete(contentOfs: items)
}
}
// MARK: - Asynchronous operations // MARK: - Asynchronous operations
/// Adds or update an instance asynchronously and waits for network operations /// Adds or update an instance asynchronously and waits for network operations
func addOrUpdateAsync(instance: T) async throws { func addOrUpdateAsync(instance: T) async throws {
if let result = _addOrUpdateCore(instance: instance) { let result = _addOrUpdateCore(instance: instance)
if result.isNewItem { if result.method == .insert {
try await self._executeBatchOnce(OperationBatch(insert: result.item)) try await self._executeBatchOnce(OperationBatch(insert: instance))
} else { } else {
try await self._executeBatchOnce(OperationBatch(update: result.item)) try await self._executeBatchOnce(OperationBatch(update: instance))
}
} }
} }
@ -288,16 +295,13 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
/// 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 deleteAsync(contentOfs sequence: any RandomAccessCollection<T>) async throws { public func deleteAsync(contentOfs sequence: any RandomAccessCollection<T>) async throws {
guard sequence.isNotEmpty else { return } guard sequence.isNotEmpty else { return }
let batch = self._deleteCore(contentOfs: sequence) let batch = self._deleteCore(contentOfs: sequence, actionOption: .syncedCascade)
try await self._executeBatchOnce(batch) try await self._executeBatchOnce(batch)
} }
/// Deletes an instance and writes /// Deletes an instance and writes
func deleteAsync(instance: T) async throws { func deleteAsync(instance: T) async throws {
defer { self.collection.delete(instance: instance, actionOption: .syncedCascade)
self.triggerWrite()
}
self.deleteItem(instance, shouldBeSynchronized: true)
self.storeCenter.createDeleteLog(instance) self.storeCenter.createDeleteLog(instance)
try await self._executeBatchOnce(OperationBatch(delete: instance)) try await self._executeBatchOnce(OperationBatch(delete: instance))
} }
@ -306,51 +310,63 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
/// Adds or update an instance without synchronizing it /// Adds or update an instance without synchronizing it
func addOrUpdateNoSync(_ instance: T) { func addOrUpdateNoSync(_ instance: T) {
self.addOrUpdateItem(instance: instance) self.collection.addOrUpdate(instance: instance)
// self.addOrUpdateItem(instance: instance)
} }
/// Adds or update a sequence of elements without synchronizing it /// Adds or update a sequence of elements without synchronizing it
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) { func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) {
self.addSequence(sequence) self.collection.addOrUpdate(contentOfs: sequence)
} }
/// Deletes the instance in the collection without synchronization /// Deletes the instance in the collection without synchronization
func deleteNoSync(contentOfs sequence: any Sequence<T>) { // func deleteNoSync(instance: T) {
defer { // self.collection.delete(instance: instance)
self.triggerWrite() // }
}
for item in sequence {
self.deleteItem(item, shouldBeSynchronized: false)
}
}
/// Deletes the instance in the collection without synchronization /// Deletes the instance in the collection without synchronization
func deleteNoSync(instance: T) { func deleteNoSync(contentOfs sequence: any RandomAccessCollection<T>) {
defer { self.collection.delete(contentOfs: sequence)
self.triggerWrite()
}
self.deleteItem(instance, shouldBeSynchronized: false)
} }
func deleteUnusedShared(instance: T) { func deleteUnusedShared(instance: T) {
guard instance.shared == true else { return } guard instance.shared == true else { return }
// Delete the instance and its non-used shared dependencies self.delete(instance: instance)
self.deleteUnusedShared(instance, shouldBeSynchronized: false) instance.deleteUnusedSharedDependencies(store: self.store)
self.triggerWrite()
} }
/// Deletes the instance in the collection without synchronization /// Deletes the instance in the collection without synchronization
func deleteByStringIdNoSync(_ id: String) { func deleteNoSyncNoCascade(id: String) {
defer { self.collection.deleteByStringId(id, actionOption: .standard)
self.triggerWrite() }
// MARK: - Collection Delegate
func loadingForMemoryCollection() async {
do {
try await self.loadDataFromServerIfAllowed()
} catch {
Logger.error(error)
} }
let realId = T.buildRealId(id: id) }
if let instance = self.findById(realId) {
self.deleteItem(instance, shouldBeSynchronized: false) func itemMerged(_ pendingOperation: PendingOperation<T>) {
let batch = OperationBatch<T>()
switch pendingOperation.method {
case .add:
batch.inserts.append(pendingOperation.data)
case .update:
batch.updates.append(pendingOperation.data)
case .delete:
batch.deletes.append(pendingOperation.data)
case .deleteUnusedShared:
break
} }
Task { await self._sendOperationBatch(batch) }
} }
// MARK: - Send requests // MARK: - Send requests
@ -397,14 +413,15 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
/// Adds or update an instance if it is newer than the local instance /// Adds or update an instance if it is newer than the local instance
func addOrUpdateIfNewer(_ instance: T, shared: Bool) { func addOrUpdateIfNewer(_ instance: T, shared: Bool) {
defer {
self.triggerWrite() // defer {
} // self.triggerWrite()
// }
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.collection.items.firstIndex(where: { $0.id == instance.id }) {
let localInstance = self.items[index] let localInstance = self.collection.items[index]
if instance.lastUpdate > localInstance.lastUpdate { if instance.lastUpdate > localInstance.lastUpdate {
self.updateItem(instance, index: index) self.collection.update(instance, index: index, actionOption: .standard)
} else { } else {
print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)") print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)")
} }
@ -412,7 +429,7 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
if shared { if shared {
instance.shared = true instance.shared = true
} }
self.addItem(instance: instance, shouldBeSynchronized: false) self.collection.add(instance: instance, actionOption: .standard)
} }
} }
@ -421,14 +438,37 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
/// Sends a POST request for the instance, and changes the collection to perform a write /// Sends a POST request for the instance, and changes the collection to perform a write
public func writeChangeAndInsertOnServer(instance: T) { public func writeChangeAndInsertOnServer(instance: T) {
self.collection.addOrUpdate(instance: instance)
Task { Task {
await self._sendInsertion(instance) await self._sendInsertion(instance)
await MainActor.run {
self.triggerWrite()
}
} }
} }
// MARK: - SomeCollection
public var hasLoaded: Bool { return self.collection.hasLoaded}
public var inMemory: Bool { return self.collection.inMemory }
public var type: any Storable.Type { return T.self }
public func referenceCount<S>(type: S.Type, id: String) -> Int where S : Storable {
return self.collection.referenceCount(type: type, id: id)
}
public func reset() {
self.collection.reset()
}
public func findById(_ id: T.ID) -> T? {
return self.collection.findById(id)
}
public var items: [T] {
return self.collection.items
}
} }
class OperationBatch<T> { class OperationBatch<T> {
@ -459,3 +499,25 @@ class OperationBatch<T> {
self.deletes.append(instance) self.deletes.append(instance)
} }
} }
extension SyncedCollection: RandomAccessCollection {
public var startIndex: Int { return self.collection.items.startIndex }
public var endIndex: Int { return self.collection.items.endIndex }
public func index(after i: Int) -> Int {
return self.collection.items.index(after: i)
}
public subscript(index: Int) -> T {
get {
return self.collection.items[index]
}
set(newValue) {
self.collection.update(newValue, index: index, actionOption: .standard)
// self.collection.items[index] = newValue
// self._triggerWrite = true
}
}
}

@ -30,13 +30,7 @@ public protocol SideStorable {
var storeId: String? { get set } var storeId: String? { get set }
} }
public extension SyncedStorable { extension Storable {
func copy() -> Self {
let copy = Self()
copy.copy(from: self)
return copy
}
func getStoreId() -> String? { func getStoreId() -> String? {
if let alt = self as? SideStorable { if let alt = self as? SideStorable {
@ -46,3 +40,13 @@ public extension SyncedStorable {
} }
} }
public extension SyncedStorable {
func copy() -> Self {
let copy = Self()
copy.copy(from: self)
return copy
}
}

@ -0,0 +1,17 @@
//
// String+Extensions.swift
// LeStorage
//
// Created by Laurent Morvillier on 19/05/2025.
//
import Foundation
public extension String {
static func random(length: Int = 10) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return String((0..<length).map{ _ in letters.randomElement()! })
}
}

@ -55,7 +55,8 @@ struct IdentifiableTests {
let stringObjects: StoredCollection<StringObject> let stringObjects: StoredCollection<StringObject>
init() { init() {
let storeCenter = StoreCenter.main let dir = "test_" + String.random()
let storeCenter: StoreCenter = StoreCenter(directoryName:dir)
intObjects = storeCenter.mainStore.registerCollection() intObjects = storeCenter.mainStore.registerCollection()
stringObjects = storeCenter.mainStore.registerCollection() stringObjects = storeCenter.mainStore.registerCollection()
} }

@ -67,7 +67,7 @@ struct StoredCollectionTests {
let item = MockStorable(id: "1", name: "Test") let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item) collection.addOrUpdate(instance: item)
collection.deleteById("1") collection.deleteByStringId("1")
let search = collection.findById("1") let search = collection.findById("1")
#expect(search == nil) #expect(search == nil)
} }

Loading…
Cancel
Save