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/StoredCollection.swift

304 lines
9.1 KiB

//
// StoredCollection.swift
// LeStorage
//
// Created by Laurent Morvillier on 02/02/2024.
//
import Foundation
protocol SomeCollection : Identifiable {
func allItems() -> [any Storable]
func deleteById(_ id: String) throws
}
extension Notification.Name {
public static let CollectionDidLoad: Notification.Name = Notification.Name.init("notification.collectionDidLoad")
public static let CollectionDidChange: Notification.Name = Notification.Name.init("notification.collectionDidChange")
}
public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollection {
/// If true, will synchronize the data with the provided server located at the Store's synchronizationApiURL
let synchronized: Bool
/// The list of stored items
@Published public fileprivate(set) var items: [T] = [] {
didSet {
self._hasChanged = true
}
}
/// The reference to the Store
fileprivate var _store: Store
/// Notifies the closure when the loading is done
fileprivate var loadCompletion: ((StoredCollection<T>) -> ())? = nil
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _index: [String : 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
}
}
}
/// Denotes a collection that loads and writes asynchronousIO
fileprivate var asynchronousIO: Bool = true
init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, loadCompletion: ((StoredCollection<T>) -> ())? = nil) {
self.synchronized = synchronized
self.asynchronousIO = asynchronousIO
if indexed {
self._index = [:]
}
self._store = store
self.loadCompletion = loadCompletion
self._load()
}
// MARK: - Loading
/// Migrates if necessary and asynchronously decodes the json file
fileprivate func _load() {
do {
let url = try FileUtils.directoryURLForFileName(T.fileName())
if FileManager.default.fileExists(atPath: url.path()) {
if self.asynchronousIO {
Task(priority: .high) {
try await Store.main.performMigrationIfNecessary(self)
try self._decodeJSONFile()
}
} else {
try self._decodeJSONFile()
}
}
// else {
// try? self.loadDataFromServer()
// }
} catch {
Logger.log(error)
}
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws {
let jsonString = try FileUtils.readDocumentFile(fileName: T.fileName())
if let decoded: [T] = try jsonString.decodeArray() {
DispatchQueue.main.async {
Logger.log("loaded \(T.fileName()) with \(decoded.count) items")
self.items = decoded
self._updateIndexIfNecessary()
self.loadCompletion?(self)
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
}
}
}
/// Updates the whole index with the items array
fileprivate func _updateIndexIfNecessary() {
if let index = self._index {
self._index = self.items.dictionary(handler: { $0.stringId })
}
}
/// Retrieves the data from the server and loads it into the items array
public func loadDataFromServer() throws {
guard self.synchronized else {
throw StoreError.unSynchronizedCollection
}
Task {
do {
self.items = try await self._store.getItems()
} catch {
Logger.error(error)
}
}
}
// 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) {
defer {
self._hasChanged = true
}
// update
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance
self._sendUpdateIfNecessary(instance)
} else { // insert
self.items.append(instance)
self._index?[instance.stringId] = instance
self._sendInsertionIfNecessary(instance)
}
}
/// Deletes the instance in the collection by id
public func delete(instance: T) throws {
defer {
self._hasChanged = true
}
try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._index?.removeValue(forKey: instance.stringId)
self._sendDeletionIfNecessary(instance)
}
/// Inserts the whole sequence into the items array, no updates
public func append(contentOfs sequence: any Sequence<T>) {
defer {
self._hasChanged = true
}
self.items.append(contentsOf: sequence)
for instance in sequence {
self._sendUpdateIfNecessary(instance)
}
}
/// Returns the instance corresponding to the provided [id]
public func findById(_ id: String) -> T? {
if let index = self._index, let instance = index[id] {
return instance
}
return self.items.first(where: { $0.id == id })
}
/// Deletes the instance corresponding to the provided [id]
public func deleteById(_ id: String) throws {
if let instance = self.findById(id) {
try self.delete(instance: instance)
}
}
/// Proceeds to "hard" delete the items without synchronizing them
public func deleteDependencies(_ items: any Sequence<T>) {
defer {
self._hasChanged = true
}
for item in items {
self.items.removeAll(where: { $0.id == item.id })
}
}
// MARK: - SomeCall
/// Returns the collection items as [any Storable]
func allItems() -> [any Storable] {
return self.items
}
// MARK: - File access
/// Schedules a write operation
fileprivate func _scheduleWrite() {
if self.asynchronousIO {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).async {
self._write()
}
} else {
self._write()
}
}
/// Writes all the items as a json array inside a file
fileprivate func _write() {
do {
let jsonString: String = try self.items.jsonString()
let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: T.fileName())
} catch {
Logger.error(error) // TODO how to notify the main project
}
}
// MARK: - Synchronization
/// Sends an insert api call for the provided [instance]
fileprivate func _sendInsertionIfNecessary(_ instance: T) {
guard self.synchronized else {
return
}
Logger.log("Call service...")
Task {
do {
let _ = try await self._store.service?.insert(instance)
} catch {
Logger.error(error)
}
}
}
/// Sends an update api call for the provided [instance]
fileprivate func _sendUpdateIfNecessary(_ instance: T) {
guard self.synchronized else {
return
}
Task {
do {
let _ = try await self._store.service?.insert(instance)
} catch {
Logger.error(error)
}
}
}
/// Sends an delete api call for the provided [instance]
fileprivate func _sendDeletionIfNecessary(_ instance: T) {
guard self.synchronized else {
return
}
Task {
do {
let _ = try await self._store.service?.delete(instance)
} catch {
Logger.error(error)
}
}
}
// 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
}
}
public func append(_ newElement: T) {
self.addOrUpdate(instance: newElement)
}
}