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.
 
 
LeStorage/LeStorage/BaseCollection.swift

494 lines
15 KiB

//
// BaseCollection.swift
// LeStorage
//
// Created by Laurent Morvillier on 02/02/2024.
//
import Foundation
public protocol CollectionHolder {
associatedtype Item: Storable
var items: [Item] { get }
func reset()
}
public protocol SomeCollection: CollectionHolder, Identifiable {
var resourceName: String { get }
var hasLoaded: Bool { get }
var inMemory: Bool { get }
var type: any Storable.Type { get }
func referenceCount<S: Storable>(type: S.Type, id: String) -> Int
func findById(_ id: Item.ID) -> Item?
}
public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
/// 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
/// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _hasChanged: Bool = false {
didSet {
if self._hasChanged == true {
self._scheduleWrite()
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidChange, object: self)
}
self._hasChanged = false
}
}
}
/// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false
/// Sets a max number of items inside the collection
fileprivate(set) var limit: Int? = nil
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false) {
if indexed {
self._indexes = [:]
}
self.inMemory = inMemory
self.store = store
self.limit = limit
if synchronousLoading {
Task {
await self.loadFromFile()
}
} else {
Task(priority: .high) {
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
func setChanged() {
self._hasChanged = true
}
/// Migrates if necessary and asynchronously decodes the json file
func load() async {
if !self.inMemory {
await self.loadFromFile()
} else {
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)
}
}
/// 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.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]) {
for item in items {
item.store = self.store
}
self.items = items
self._updateIndexIfNecessary()
}
/// 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
public func addOrUpdate(instance: T) {
self.addOrUpdateItem(instance: instance)
}
/// Adds or update an instance inside the collection and writes
func addOrUpdateItem(instance: T) {
defer {
self._hasChanged = true
}
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.updateItem(instance, index: index)
} else {
self.addItem(instance: instance)
}
}
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer {
self._hasChanged = true
}
self.items.removeAll()
self.addItem(instance: instance)
}
/// Deletes an item by its id
public func deleteById(_ id: T.ID) {
if let instance = self.findById(id) {
self.delete(instance: instance)
}
}
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T) {
defer {
self._hasChanged = true
}
self.deleteItem(instance)
}
/// 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>) {
defer {
self._hasChanged = 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)
}
/// Adds a sequence of objects inside the collection and performs a write
func addSequence(_ sequence: any Sequence<T>, checkLoaded: Bool = true) {
defer {
self._hasChanged = true
}
for instance in sequence {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.updateItem(instance, index: index, checkLoaded: checkLoaded)
} else { // insert
self.addItem(instance: instance, checkLoaded: checkLoaded)
}
}
}
/// 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")
}
}
}
/// Adds an instance to the collection
@discardableResult func addItem(instance: T, checkLoaded: Bool = true, shouldBeSynchronized: Bool = false) -> Bool {
if checkLoaded && !self.hasLoaded {
self._addPendingOperation(method: .addOrUpdate, instance: instance, shouldBeSynchronized: shouldBeSynchronized)
return false
}
self._affectStoreIdIfNecessary(instance: instance)
self.items.append(instance)
instance.store = self.store
self._indexes?[instance.id] = instance
self._applyLimitIfPresent()
return true
}
/// Updates an instance to the collection by index
@discardableResult func updateItem(_ instance: T, index: Int, checkLoaded: Bool = true, shouldBeSynchronized: Bool = false) -> Bool {
if checkLoaded && !self.hasLoaded {
self._addPendingOperation(method: .addOrUpdate, instance: instance, shouldBeSynchronized: shouldBeSynchronized)
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 func deleteItem(_ instance: T, shouldBeSynchronized: Bool = false) -> Bool {
if !self.hasLoaded {
self._addPendingOperation(method: .addOrUpdate, instance: instance, shouldBeSynchronized: shouldBeSynchronized)
return false
}
instance.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized)
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
fileprivate func _applyLimitIfPresent() {
if let limit {
self.items = self.items.suffix(limit)
}
}
/// 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._hasChanged = true
}
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)
}
}
}
/// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls
public func deleteAll() throws {
self.delete(contentOfs: self.items)
}
// MARK: - Pending operations
fileprivate func _addPendingOperation(method: StorageMethod, instance: T, shouldBeSynchronized: Bool) {
if self.pendingOperationManager == nil {
self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory)
}
self.addPendingOperation(method: method, instance: instance, shouldBeSynchronized: false)
}
func addPendingOperation(method: StorageMethod, instance: T, shouldBeSynchronized: Bool) {
self.pendingOperationManager?.addPendingOperation(method: method, instance: instance, shouldBeSynchronized: shouldBeSynchronized)
}
fileprivate func _mergePendingOperations() {
guard let manager = self.pendingOperationManager, manager.items.isNotEmpty else { return }
Logger.log(">>> Merge pending: \(manager.items.count)")
for item in manager.items {
let data = item.data
switch (item.method, item.shouldBeSynchronized) {
case (.addOrUpdate, true):
self.addOrUpdate(instance: data)
case (.addOrUpdate, false):
self.addOrUpdateItem(instance: data)
case (.delete, true):
self.delete(instance: data)
case (.delete, false):
self.deleteItem(data)
}
}
// let methodGroups = manager.items.group { $0.method }
// for (method, group) in methodGroups {
// let dataArray = group.map { $0.data }
// switch method {
// case .addOrUpdate:
// self.addOrUpdate(contentOfs: dataArray)
// case .delete:
// self.delete(contentOfs: dataArray)
// }
// }
self.pendingOperationManager = nil
}
// MARK: - File access
/// Schedules a write operation
fileprivate func _scheduleWrite() {
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()
}
}
/// Writes all the items as a json array inside a file
fileprivate func _write() {
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)")
}
}
/// 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)
setChanged()
}
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 referenceCount<S: Storable>(type: S.Type, id: String) -> Int {
let relationships = T.relationships().filter { $0.type == type }
guard relationships.count > 0 else { return 0 }
return self.items.reduce(0) { count, item in
count
+ relationships.filter { relationship in
(item[keyPath: relationship.keyPath] as? String) == id
}.count
}
}
}
public class StoredCollection<T: Storable>: BaseCollection<T>, RandomAccessCollection {
/// Returns a dummy StoredCollection instance
public static func placeholder() -> StoredCollection<T> {
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._hasChanged = true
}
}
}
extension SyncedCollection: RandomAccessCollection {
public var startIndex: Int { return self.items.startIndex }
public var endIndex: Int { return self.items.endIndex }
public func index(after i: Int) -> Int {
return self.items.index(after: i)
}
public subscript(index: Int) -> T {
get {
return self.items[index]
}
set(newValue) {
self.items[index] = newValue
self._hasChanged = true
}
}
}