diff --git a/LeStorage/BaseCollection.swift b/LeStorage/BaseCollection.swift index 15d0f3f..b4de0b5 100644 --- a/LeStorage/BaseCollection.swift +++ b/LeStorage/BaseCollection.swift @@ -306,7 +306,7 @@ public class BaseCollection: SomeCollection, CollectionHolder { @discardableResult func deleteItem(_ instance: T, shouldBeSynchronized: Bool = false) -> Bool { if !self.hasLoaded { - self.addPendingOperation(method: .addOrUpdate, instance: instance, shouldBeSynchronized: shouldBeSynchronized) + self.addPendingOperation(method: .delete, instance: instance, shouldBeSynchronized: shouldBeSynchronized) return false } @@ -314,9 +314,29 @@ public class BaseCollection: SomeCollection, CollectionHolder { instance.deleteDependencies(store: self.store, shouldBeSynchronized: shouldBeSynchronized) } + self.localDeleteOnly(instance: instance) + return true + } + + /// Deletes an instance from the collection + @discardableResult func deleteUnusedShared(_ instance: T, shouldBeSynchronized: Bool = false) -> Bool { + + if !self.hasLoaded { + self.addPendingOperation(method: .deleteUnusedShared, instance: instance, shouldBeSynchronized: shouldBeSynchronized) + return false + } + + // For shared objects, we need to check for dependencies that are also shared + // but not used elsewhere before deleting them + instance.deleteUnusedSharedDependencies(store: self.store) + + self.localDeleteOnly(instance: instance) + return true + } + + func localDeleteOnly(instance: T) { self.items.removeAll { $0.id == instance.id } self._indexes?.removeValue(forKey: instance.id) - return true } /// If the collection has more instance that its limit, remove the surplus @@ -382,6 +402,8 @@ public class BaseCollection: SomeCollection, CollectionHolder { self.delete(instance: data) case (.delete, false): self.deleteItem(data) + case (.deleteUnusedShared, _): + self.deleteUnusedShared(data) } } diff --git a/LeStorage/Codables/ApiCall.swift b/LeStorage/Codables/ApiCall.swift index 8f362d7..28bc65e 100644 --- a/LeStorage/Codables/ApiCall.swift +++ b/LeStorage/Codables/ApiCall.swift @@ -69,6 +69,9 @@ public class ApiCall: ModelObject, Storable, SomeCall { public func copy(from other: any Storable) { fatalError("should not happen") } + public func copyForUpdate(from other: any Storable) { + fatalError("should not happen") + } func formattedURLParameters() -> String? { return self.urlParameters?.toQueryString() @@ -157,7 +160,10 @@ class OldApiCall: ModelObject, Storable, SomeCall { func copy(from other: any Storable) { fatalError("should not happen") } - + public func copyForUpdate(from other: any Storable) { + fatalError("should not happen") + } + func formattedURLParameters() -> String? { return self.urlParameters?.toQueryString() } diff --git a/LeStorage/Codables/DataAccess.swift b/LeStorage/Codables/DataAccess.swift index f791c62..8b2e5f5 100644 --- a/LeStorage/Codables/DataAccess.swift +++ b/LeStorage/Codables/DataAccess.swift @@ -70,4 +70,8 @@ class DataAccess: SyncedModelObject, SyncedStorable { self.grantedAt = dataAccess.grantedAt } + public func copyForUpdate(from other: any Storable) { + self.copy(from: other) + } + } diff --git a/LeStorage/Codables/DataLog.swift b/LeStorage/Codables/DataLog.swift index 12c4118..4dfe94b 100644 --- a/LeStorage/Codables/DataLog.swift +++ b/LeStorage/Codables/DataLog.swift @@ -33,5 +33,8 @@ class DataLog: ModelObject, Storable { func copy(from other: any Storable) { fatalError("should not happen") } - + public func copyForUpdate(from other: any Storable) { + fatalError("should not happen") + } + } diff --git a/LeStorage/Codables/FailedAPICall.swift b/LeStorage/Codables/FailedAPICall.swift index 6b340bd..855db57 100644 --- a/LeStorage/Codables/FailedAPICall.swift +++ b/LeStorage/Codables/FailedAPICall.swift @@ -102,5 +102,8 @@ class FailedAPICall: SyncedModelObject, SyncedStorable { self.error = fac.error self.authentication = fac.authentication } + public func copyForUpdate(from other: any Storable) { + self.copy(from: other) + } } diff --git a/LeStorage/Codables/GetSyncData.swift b/LeStorage/Codables/GetSyncData.swift index 3c6999b..ae9252b 100644 --- a/LeStorage/Codables/GetSyncData.swift +++ b/LeStorage/Codables/GetSyncData.swift @@ -36,7 +36,9 @@ class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible { guard let getSyncData = other as? GetSyncData else { return } self.date = getSyncData.date } - + public func copyForUpdate(from other: any Storable) { + fatalError("should not happen") + } func queryParameters(storeCenter: StoreCenter) -> [String : String] { return ["last_update" : self._formattedLastUpdate, "device_id" : storeCenter.deviceId()] diff --git a/LeStorage/Codables/Log.swift b/LeStorage/Codables/Log.swift index 665bb18..5d13b2b 100644 --- a/LeStorage/Codables/Log.swift +++ b/LeStorage/Codables/Log.swift @@ -59,5 +59,8 @@ class Log: SyncedModelObject, SyncedStorable { self.date = log.date self.message = log.message } + public func copyForUpdate(from other: any Storable) { + fatalError("should not happen") + } } diff --git a/LeStorage/Codables/PendingOperation.swift b/LeStorage/Codables/PendingOperation.swift index b96136d..c982b60 100644 --- a/LeStorage/Codables/PendingOperation.swift +++ b/LeStorage/Codables/PendingOperation.swift @@ -10,6 +10,7 @@ import Foundation enum StorageMethod: String, Codable { case addOrUpdate case delete + case deleteUnusedShared } class PendingOperation: Codable, Equatable { diff --git a/LeStorage/ModelObject.swift b/LeStorage/ModelObject.swift index 0c5f32a..6f6e859 100644 --- a/LeStorage/ModelObject.swift +++ b/LeStorage/ModelObject.swift @@ -18,6 +18,11 @@ open class ModelObject: NSObject { open func deleteDependencies(store: Store, shouldBeSynchronized: Bool) { } + + open func deleteUnusedSharedDependencies(store: Store) { + // Default implementation does nothing + // Subclasses should override this to handle their specific dependencies + } static var relationshipNames: [String] = [] diff --git a/LeStorage/Relationship.swift b/LeStorage/Relationship.swift index a3b58fe..7b0fe17 100644 --- a/LeStorage/Relationship.swift +++ b/LeStorage/Relationship.swift @@ -7,9 +7,10 @@ public struct Relationship { - public init(type: any Storable.Type, keyPath: AnyKeyPath) { + public init(type: any Storable.Type, keyPath: AnyKeyPath, mainStoreLookup: Bool) { self.type = type self.keyPath = keyPath + self.mainStoreLookup = mainStoreLookup } /// The type of the relationship @@ -17,4 +18,8 @@ public struct Relationship { /// the keyPath to access the relationship var keyPath: AnyKeyPath + + /// Indicates whether the linked object is on the main Store + var mainStoreLookup: Bool + } diff --git a/LeStorage/Storable.swift b/LeStorage/Storable.swift index e901387..7bdbf11 100644 --- a/LeStorage/Storable.swift +++ b/LeStorage/Storable.swift @@ -23,6 +23,11 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol { /// so when we do that on the server, we also need to do it locally func deleteDependencies(store: Store, shouldBeSynchronized: Bool) + /// A method that deletes dependencies of shared resources, but only if they are themselves shared + /// and not referenced by other objects in the store + /// This is used when cleaning up shared objects that are no longer in use + func deleteUnusedSharedDependencies(store: Store) + /// Copies the content of another item into the instance /// This behavior has been made to get live updates when looking at properties in SwiftUI screens func copy(from other: any Storable) diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index 4965368..8b7a8b0 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -46,7 +46,7 @@ public enum StoreError: Error, LocalizedError { final public class Store { - fileprivate(set) var storeCenter: StoreCenter + public fileprivate(set) var storeCenter: StoreCenter /// The dictionary of registered collections fileprivate var _collections: [String : any SomeCollection] = [:] @@ -168,6 +168,11 @@ final public class Store { throw StoreError.collectionNotRegistered(type: T.resourceName()) } + /// Returns a collection by type + func syncedCollection(type: T.Type) throws -> SyncedCollection { + return try self.syncedCollection() + } + /// Returns a collection by type func collection() throws -> BaseCollection { if let collection = self._collections[T.resourceName()] as? BaseCollection { @@ -255,6 +260,46 @@ final public class Store { return count } + public func deleteUnusedSharedIfNecessary(_ instance: T) { + if self.referenceCount(type: T.self, id: instance.stringId) == 0 { + do { + let collection: SyncedCollection = try self.syncedCollection() + collection.deleteUnusedShared(instance: instance) + } catch { + Logger.error(error) + } + } + } + + public func deleteUnusedSharedDependencies(type: T.Type, shouldBeSynchronized: Bool, _ handler: (T) throws -> Bool) { + do { + let collection: BaseCollection = try self.collection() + let items = try collection.items.filter(handler) + self.deleteUnusedSharedDependencies(items) + } catch { + Logger.error(error) + } + + } + + /// Deletes dependencies of shared objects that are not used elsewhere in the system + /// Similar to _deleteDependencies but only for unused shared objects + public func deleteUnusedSharedDependencies(_ items: [T]) { + do { + for item in items { + guard item.shared == true else { continue } + if self.referenceCount(type: T.self, id: item.stringId) == 0 { + // Only delete if the shared item has no references + item.deleteUnusedSharedDependencies(store: self) + let collection: SyncedCollection = try self.syncedCollection() + collection.deleteUnusedShared(instance: item) + } + } + } catch { + Logger.error(error) + } + } + public func deleteAllDependencies(type: T.Type, shouldBeSynchronized: Bool) { do { let collection: BaseCollection = try self.collection() diff --git a/LeStorage/SyncedCollection.swift b/LeStorage/SyncedCollection.swift index 09b55f5..b6d071b 100644 --- a/LeStorage/SyncedCollection.swift +++ b/LeStorage/SyncedCollection.swift @@ -114,6 +114,9 @@ public class SyncedCollection: BaseCollection, SomeSynced if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if self.updateItem(instance, index: index, shouldBeSynchronized: true) { self.setChanged() + if instance.shared == true { + self._cleanUpSharedDependencies() + } return (instance, false) } } else { @@ -146,6 +149,9 @@ public class SyncedCollection: BaseCollection, SomeSynced } } } + + self._cleanUpSharedDependencies() + return batch } @@ -232,6 +238,39 @@ public class SyncedCollection: BaseCollection, SomeSynced await self._sendOperationBatch(batch) } + fileprivate func _cleanUpSharedDependencies() { + for relationship in T.relationships() { + if let syncedType = relationship.type as? (any SyncedStorable.Type) { + do { + try self._deleteUnusedSharedInstances(relationship: relationship, type: syncedType) + } catch { + Logger.error(error) + } + } + } + } + + fileprivate func _deleteUnusedSharedInstances(relationship: Relationship, type: S.Type) throws { + + let store: Store + if relationship.mainStoreLookup { + store = self.store.storeCenter.mainStore + } else { + store = self.store + } + + let collection: SyncedCollection = try store.syncedCollection() + collection._deleteUnusedSharedInstances() + } + + fileprivate func _deleteUnusedSharedInstances() { + + let sharedItems = self.items.filter { $0.shared == true } + for sharedItem in sharedItems { + self.store.deleteUnusedSharedIfNecessary(sharedItem) + } + } + // MARK: - Asynchronous operations /// Adds or update an instance asynchronously and waits for network operations @@ -297,6 +336,16 @@ public class SyncedCollection: BaseCollection, SomeSynced self.deleteItem(instance, shouldBeSynchronized: false) } + func deleteUnusedShared(instance: T) { + + guard instance.shared == true else { return } + + // Delete the instance and its non-used shared dependencies + self.deleteUnusedShared(instance, shouldBeSynchronized: false) + + self.setChanged() + } + /// Deletes the instance in the collection without synchronization func deleteByStringIdNoSync(_ id: String) { defer {