You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
609 lines
19 KiB
609 lines
19 KiB
//
|
|
// StoredCollection.swift
|
|
// LeStorage
|
|
//
|
|
// Created by Laurent Morvillier on 02/02/2024.
|
|
//
|
|
|
|
import Foundation
|
|
import Combine
|
|
|
|
public protocol SomeCollection<Item>: Identifiable {
|
|
|
|
associatedtype Item: Storable
|
|
|
|
var hasLoaded: Bool { get }
|
|
var inMemory: Bool { get }
|
|
var type: any Storable.Type { get }
|
|
|
|
func reset()
|
|
func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool
|
|
|
|
var items: [Item] { get }
|
|
|
|
func deleteAllItemsAndDependencies(actionOption: ActionOption)
|
|
func deleteDependencies(actionOption: ActionOption, _ isIncluded: (Item) -> Bool)
|
|
|
|
func findById(_ id: Item.ID) -> Item?
|
|
func requestWrite()
|
|
}
|
|
|
|
protocol CollectionDelegate<Item> {
|
|
associatedtype Item: Storable
|
|
func loadingForMemoryCollection() async
|
|
func itemMerged(_ pendingOperation: PendingOperation<Item>)
|
|
}
|
|
|
|
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
|
|
var write: Bool
|
|
|
|
static let standard: ActionOption = ActionOption(synchronize: false, cascade: false, write: true)
|
|
static let noCascadeNoWrite: ActionOption = ActionOption(synchronize: false, cascade: false, write: false)
|
|
static let cascade: ActionOption = ActionOption(synchronize: false, cascade: true, write: true)
|
|
static let syncedCascade: ActionOption = ActionOption(synchronize: true, cascade: true, write: true)
|
|
}
|
|
|
|
public class StoredCollection<T: Storable>: SomeCollection {
|
|
|
|
public typealias Item = T
|
|
|
|
/// Doesn't write the collection in a file
|
|
fileprivate(set) public var inMemory: Bool = false
|
|
|
|
/// The list of stored items
|
|
@Published public fileprivate(set) var items: [T] = []
|
|
|
|
/// The reference to the Store
|
|
fileprivate(set) var store: Store
|
|
|
|
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
|
|
fileprivate var _indexes: [T.ID: T]? = nil
|
|
|
|
/// A PendingOperationManager instance that manages operations while the collection is not loaded
|
|
fileprivate(set) var pendingOperationManager: PendingOperationManager<T>? = nil
|
|
|
|
fileprivate var _writingTimer: Timer? = nil
|
|
|
|
/// Indicates whether the collection has changed, thus requiring a write operation
|
|
fileprivate var _triggerWrite: Bool = false {
|
|
didSet {
|
|
if self._triggerWrite == true && self.inMemory == false {
|
|
self._scheduleWrite()
|
|
self._triggerWrite = false
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
NotificationCenter.default.post(
|
|
name: NSNotification.Name.CollectionDidChange, object: self)
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
/// Indicates if the collection has loaded locally, with or without a file
|
|
fileprivate(set) public var hasLoaded: Bool = false
|
|
|
|
/// Sets a max number of items inside the collection
|
|
fileprivate(set) var limit: Int? = nil
|
|
|
|
fileprivate var _delegate: (any CollectionDelegate<T>)? = nil
|
|
|
|
init(store: Store, inMemory: Bool = false) async {
|
|
self.store = store
|
|
if self.inMemory == false {
|
|
await self.loadFromFile()
|
|
}
|
|
}
|
|
|
|
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false, noLoad: Bool = false, delegate: (any CollectionDelegate<T>)? = nil) {
|
|
if indexed {
|
|
self._indexes = [:]
|
|
}
|
|
self.inMemory = inMemory
|
|
self.store = store
|
|
self.limit = limit
|
|
self._delegate = delegate
|
|
|
|
if noLoad {
|
|
self.hasLoaded = true
|
|
} else {
|
|
Task {
|
|
if synchronousLoading {
|
|
await self.loadFromFile()
|
|
} else {
|
|
await self.load()
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
init(store: Store) {
|
|
self.store = store
|
|
}
|
|
|
|
var storeCenter: StoreCenter { return self.store.storeCenter }
|
|
|
|
/// Returns the name of the managed resource
|
|
public var resourceName: String {
|
|
return T.resourceName()
|
|
}
|
|
|
|
public var storeId: String? {
|
|
return self.store.identifier
|
|
}
|
|
|
|
// MARK: - Loading
|
|
|
|
/// Sets the collection as changed to trigger a write
|
|
public func requestWrite() {
|
|
self._triggerWrite = true
|
|
}
|
|
|
|
/// Migrates if necessary and asynchronously decodes the json file
|
|
func load() async {
|
|
if !self.inMemory {
|
|
await self.loadFromFile()
|
|
} else {
|
|
await self._delegate?.loadingForMemoryCollection()
|
|
await MainActor.run {
|
|
self.setAsLoaded()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Starts the JSON file decoding synchronously or asynchronously
|
|
func loadFromFile() async {
|
|
do {
|
|
try await self._decodeJSONFile()
|
|
} catch {
|
|
Logger.error(error)
|
|
await MainActor.run {
|
|
self.setAsLoaded()
|
|
}
|
|
do {
|
|
let fileURL = try self.store.fileURL(type: T.self)
|
|
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
|
|
if !jsonString.isEmpty {
|
|
StoreCenter.main.log(message: "Could not decode: \(jsonString)")
|
|
}
|
|
} catch {
|
|
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
/// Decodes the json file into the items array
|
|
fileprivate func _decodeJSONFile() async throws {
|
|
|
|
let fileURL = try self.store.fileURL(type: T.self)
|
|
|
|
if FileManager.default.fileExists(atPath: fileURL.path()) {
|
|
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
|
|
let decoded: [T] = try jsonString.decodeArray() ?? []
|
|
self.hasLoaded = true // avoid pending management
|
|
self.setItems(decoded)
|
|
}
|
|
await MainActor.run {
|
|
self.setAsLoaded()
|
|
}
|
|
|
|
}
|
|
|
|
/// Sets the collection as loaded
|
|
/// Send a CollectionDidLoad event
|
|
@MainActor
|
|
func setAsLoaded() {
|
|
self.hasLoaded = true
|
|
self._mergePendingOperations()
|
|
|
|
NotificationCenter.default.post(
|
|
name: NSNotification.Name.CollectionDidLoad, object: self)
|
|
}
|
|
|
|
/// Sets a collection of items and indexes them
|
|
func setItems(_ items: [T]) {
|
|
self.items.removeAll()
|
|
for item in items {
|
|
self._addItem(instance: item)
|
|
}
|
|
self.requestWrite()
|
|
}
|
|
|
|
@MainActor
|
|
func loadAndWrite(_ items: [T], clear: Bool = false) {
|
|
if clear {
|
|
self.setItems(items)
|
|
self.setAsLoaded()
|
|
} else {
|
|
self.setAsLoaded()
|
|
self.addOrUpdate(contentOfs: items)
|
|
}
|
|
|
|
}
|
|
|
|
/// Updates the whole index with the items array
|
|
fileprivate func _updateIndexIfNecessary() {
|
|
if self._indexes != nil {
|
|
self._indexes = self.items.dictionary { $0.id }
|
|
}
|
|
}
|
|
|
|
// MARK: - Basic operations
|
|
|
|
/// Adds or updates the provided instance inside the collection
|
|
/// Adds it if its id is not found, and otherwise updates it
|
|
@discardableResult public func addOrUpdate(instance: T) -> ActionResult<T> {
|
|
defer {
|
|
self.requestWrite()
|
|
}
|
|
return self._rawAddOrUpdate(instance: instance)
|
|
}
|
|
|
|
/// Adds or update a sequence of elements
|
|
public func addOrUpdate(contentOfs sequence: any Sequence<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
|
|
|
|
defer {
|
|
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 }) {
|
|
let updated = self._updateItem(instance, index: index, actionOption: .standard)
|
|
return ActionResult(instance: instance, method: .update, pending: !updated)
|
|
} else {
|
|
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
|
|
func setSingletonNoSync(instance: T) {
|
|
defer {
|
|
self.requestWrite()
|
|
}
|
|
self.items.removeAll()
|
|
self._addItem(instance: instance)
|
|
}
|
|
|
|
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
|
|
public func delete(instance: T) {
|
|
self.delete(instance: instance, actionOption: .cascade)
|
|
}
|
|
|
|
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
|
|
public func delete(instance: T, actionOption: ActionOption) {
|
|
defer {
|
|
self._triggerWrite = true
|
|
}
|
|
self.deleteItem(instance, actionOption: actionOption)
|
|
}
|
|
|
|
/// 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>, _ handler: ((ActionResult<T>) -> ())? = nil) {
|
|
self.delete(contentOfs: sequence, actionOption: .cascade, handler: handler)
|
|
}
|
|
|
|
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption, handler: ((ActionResult<T>) -> ())? = nil) {
|
|
|
|
defer {
|
|
self._triggerWrite = true
|
|
}
|
|
|
|
for instance in sequence {
|
|
let deleted = self.deleteItem(instance, actionOption: actionOption)
|
|
handler?(ActionResult(instance: instance, method: .delete, pending: !deleted))
|
|
}
|
|
}
|
|
|
|
/// This method sets the storeId for the given instance if the collection belongs to a store with an id
|
|
fileprivate func _affectStoreIdIfNecessary(instance: T) {
|
|
if let storeId = self.store.identifier {
|
|
if var altStorable = instance as? SideStorable {
|
|
altStorable.storeId = storeId
|
|
} else {
|
|
fatalError("instance does not implement SideStorable, thus sync cannot work")
|
|
}
|
|
}
|
|
}
|
|
|
|
func add(instance: T, actionOption: ActionOption) {
|
|
self._addItem(instance: instance, actionOption: actionOption)
|
|
}
|
|
|
|
/// Adds an instance to the collection
|
|
@discardableResult fileprivate func _addItem(instance: T, actionOption: ActionOption = .standard) -> Bool {
|
|
|
|
if !self.hasLoaded {
|
|
self.addPendingOperation(method: .add, instance: instance, actionOption: actionOption)
|
|
return false
|
|
}
|
|
|
|
self._affectStoreIdIfNecessary(instance: instance)
|
|
self.items.append(instance)
|
|
instance.store = self.store
|
|
self._indexes?[instance.id] = instance
|
|
self._applyLimitIfPresent()
|
|
|
|
if T.storeParent() {
|
|
_ = self.storeCenter.requestStore(identifier: instance.stringId) // make directory
|
|
}
|
|
|
|
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
|
|
@discardableResult fileprivate func _updateItem(_ instance: T, index: Int, actionOption: ActionOption) -> Bool {
|
|
|
|
if !self.hasLoaded {
|
|
self.addPendingOperation(method: .update, instance: instance, actionOption: actionOption)
|
|
return false
|
|
}
|
|
|
|
let item = self.items[index]
|
|
if item !== instance {
|
|
self.items[index].copy(from: instance)
|
|
}
|
|
|
|
instance.store = self.store
|
|
self._indexes?[instance.id] = instance
|
|
return true
|
|
}
|
|
|
|
/// Deletes an instance from the collection
|
|
@discardableResult fileprivate func deleteItem(_ instance: T, actionOption: ActionOption = .cascade) -> Bool {
|
|
|
|
if !self.hasLoaded {
|
|
self.addPendingOperation(method: .delete, instance: instance, actionOption: actionOption)
|
|
return false
|
|
}
|
|
|
|
if actionOption.cascade {
|
|
instance.deleteDependencies(store: self.store, actionOption: actionOption)
|
|
}
|
|
|
|
self.localDeleteOnly(instance: instance)
|
|
|
|
if T.storeParent() {
|
|
self.storeCenter.destroyStore(identifier: instance.stringId)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/// Deletes an instance from the collection
|
|
@discardableResult func deleteUnusedShared(_ instance: T, actionOption: ActionOption) -> Bool {
|
|
|
|
if !self.hasLoaded {
|
|
self.addPendingOperation(method: .deleteUnusedShared, instance: instance, actionOption: actionOption)
|
|
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)
|
|
}
|
|
|
|
/// If the collection has more instance that its limit, remove the surplus
|
|
fileprivate func _applyLimitIfPresent() {
|
|
if let limit {
|
|
self.items = self.items.suffix(limit)
|
|
}
|
|
}
|
|
|
|
func deleteByStringId(_ id: String, actionOption: ActionOption = .cascade) {
|
|
let realId = T.buildRealId(id: id)
|
|
if let instance = self.findById(realId) {
|
|
self.deleteItem(instance, actionOption: actionOption)
|
|
}
|
|
if actionOption.write {
|
|
self.requestWrite()
|
|
}
|
|
}
|
|
|
|
/// Returns the instance corresponding to the provided [id]
|
|
public func findById(_ id: T.ID) -> T? {
|
|
if let index = self._indexes, let instance = index[id] {
|
|
return instance
|
|
}
|
|
return self.items.first(where: { $0.id == id })
|
|
}
|
|
|
|
/// Proceeds to "hard" delete the items without synchronizing them
|
|
/// Also removes related API calls
|
|
public func deleteDependencies(_ items: any Sequence<T>) {
|
|
defer {
|
|
self.requestWrite()
|
|
}
|
|
let itemsArray = Array(items) // fix error if items is self.items
|
|
for item in itemsArray {
|
|
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
|
|
self.items.remove(at: index)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) {
|
|
if self.pendingOperationManager == nil {
|
|
self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory)
|
|
}
|
|
self._addPendingOperationIfPossible(method: method, instance: instance, actionOption: actionOption)
|
|
}
|
|
|
|
fileprivate func _addPendingOperationIfPossible(method: StorageMethod, instance: T, actionOption: ActionOption) {
|
|
self.pendingOperationManager?.addPendingOperation(method: method, instance: instance, actionOption: actionOption)
|
|
}
|
|
|
|
fileprivate func _mergePendingOperations() {
|
|
|
|
guard let manager = self.pendingOperationManager, manager.items.isNotEmpty else { return }
|
|
|
|
Logger.log(">>> Merge pending \(manager.typeName): \(manager.items.count)")
|
|
for item in manager.items {
|
|
let data = item.data
|
|
switch item.method {
|
|
case .add, .update:
|
|
self.addOrUpdate(instance: data)
|
|
case .delete:
|
|
self.deleteItem(data, actionOption: item.actionOption)
|
|
case .deleteUnusedShared:
|
|
self.deleteUnusedShared(data, actionOption: item.actionOption)
|
|
}
|
|
|
|
self._delegate?.itemMerged(item)
|
|
}
|
|
manager.reset()
|
|
|
|
self.pendingOperationManager = nil
|
|
}
|
|
|
|
// MARK: - File access
|
|
|
|
/// Schedules a write operation
|
|
fileprivate func _scheduleWrite() {
|
|
self._cleanTimer()
|
|
DispatchQueue.main.async {
|
|
self._writingTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self._write), userInfo: nil, repeats: false)
|
|
}
|
|
}
|
|
|
|
fileprivate func _cleanTimer() {
|
|
self._writingTimer?.invalidate()
|
|
self._writingTimer = nil
|
|
}
|
|
|
|
/// Writes all the items as a json array inside a file
|
|
@objc fileprivate func _write() {
|
|
DispatchQueue(label: "lestorage.queue.write", qos: .utility).async {
|
|
do {
|
|
let jsonString: String = try self.items.jsonString()
|
|
try self.store.write(content: jsonString, fileName: T.fileName())
|
|
} catch {
|
|
Logger.error(error)
|
|
self.storeCenter.log(
|
|
message: "write failed for \(T.resourceName()): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
self._cleanTimer()
|
|
}
|
|
|
|
/// Simply clears the items of the collection
|
|
public func clear() {
|
|
self.items.removeAll()
|
|
}
|
|
|
|
/// Removes the items of the collection and deletes the corresponding file
|
|
public func reset() {
|
|
self.items.removeAll()
|
|
self.store.removeFile(type: T.self)
|
|
}
|
|
|
|
public var type: any Storable.Type { return T.self }
|
|
|
|
// MARK: - Reference count
|
|
|
|
/// Counts the references to an object - given its type and id - inside the collection
|
|
public func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool {
|
|
let relationships = T.parentRelationships().filter { $0.type == type }
|
|
guard relationships.count > 0 else { return false }
|
|
|
|
for item in self.items {
|
|
for relationship in relationships {
|
|
if item[keyPath: relationship.keyPath] as? String == id {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MARK: - for Synced Collection
|
|
|
|
@MainActor
|
|
func updateLocalInstance(_ serverInstance: T) {
|
|
if let localInstance = self.findById(serverInstance.id) {
|
|
localInstance.copy(from: serverInstance)
|
|
self.requestWrite()
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension StoredCollection: RandomAccessCollection {
|
|
|
|
public static func placeholder() -> StoredCollection<T> {
|
|
return StoredCollection<T>(store: Store(storeCenter: StoreCenter.main))
|
|
}
|
|
|
|
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._triggerWrite = true
|
|
}
|
|
}
|
|
|
|
}
|
|
|