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.
449 lines
14 KiB
449 lines
14 KiB
//
|
|
// StoredCollection.swift
|
|
// LeStorage
|
|
//
|
|
// Created by Laurent Morvillier on 02/02/2024.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
enum StoredCollectionError : Error {
|
|
case unmanagedHTTPMethod(method: String)
|
|
case missingApiCallCollection
|
|
case missingInstance
|
|
}
|
|
|
|
protocol SomeCollection : Identifiable {
|
|
func allItems() -> [any Storable]
|
|
func deleteById(_ id: String) throws
|
|
func deleteApiCallById(_ 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] = []
|
|
|
|
/// 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
|
|
|
|
fileprivate var apiCallsCollection: StoredCollection<ApiCall<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
|
|
|
|
if synchronized {
|
|
self.apiCallsCollection = StoredCollection<ApiCall<T>>(synchronized: false, store: store, loadCompletion: { apiCallCollection in
|
|
self._rescheduleApiCalls()
|
|
})
|
|
}
|
|
|
|
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 _ = 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()
|
|
self._hasChanged = true
|
|
} 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) throws {
|
|
|
|
defer {
|
|
self._hasChanged = true
|
|
}
|
|
|
|
// update
|
|
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
|
|
self.items[index] = instance
|
|
try self._sendUpdateIfNecessary(instance)
|
|
} else { // insert
|
|
self.items.append(instance)
|
|
self._index?[instance.stringId] = instance
|
|
try 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)
|
|
try self._sendDeletionIfNecessary(instance)
|
|
|
|
}
|
|
|
|
/// Inserts the whole sequence into the items array, no updates
|
|
public func append(contentOfs sequence: any Sequence<T>) throws {
|
|
defer {
|
|
self._hasChanged = true
|
|
}
|
|
self.items.append(contentsOf: sequence)
|
|
for instance in sequence {
|
|
try self._sendInsertionIfNecessary(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).sync { // sync to make sure we don't have writes performed at the same time
|
|
self._write()
|
|
}
|
|
} else {
|
|
self._write()
|
|
}
|
|
}
|
|
|
|
/// Writes all the items as a json array inside a file
|
|
fileprivate func _write() {
|
|
Logger.log("Start 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
|
|
}
|
|
Logger.log("End write")
|
|
}
|
|
|
|
// MARK: - Synchronization
|
|
|
|
fileprivate func _callForInstance(_ instance: T, method: Method) throws -> ApiCall<T>? {
|
|
guard let apiCallCollection = self.apiCallsCollection else {
|
|
throw StoredCollectionError.missingApiCallCollection
|
|
}
|
|
|
|
if let existingCall = apiCallCollection.first(where: { $0.dataId == instance.id }) {
|
|
switch existingCall.method {
|
|
case Method.post.rawValue, Method.put.rawValue:
|
|
existingCall.body = try instance.jsonString()
|
|
return existingCall
|
|
case Method.delete.rawValue:
|
|
try self.deleteApiCallById(existingCall.id)
|
|
return nil
|
|
default:
|
|
throw StoredCollectionError.unmanagedHTTPMethod(method: existingCall.method)
|
|
}
|
|
|
|
} else {
|
|
return try self._createCall(instance, method: method)
|
|
}
|
|
}
|
|
|
|
fileprivate func _createCall(_ instance: T, method: Method) throws -> ApiCall<T> {
|
|
guard let baseURL = _store.service?.baseURL else {
|
|
throw StoreError.missingService
|
|
}
|
|
let jsonString = try instance.jsonString()
|
|
let url = baseURL + T.resourceName() + "/"
|
|
return ApiCall(url: url, method: method.rawValue, dataId: String(instance.id), body: jsonString)
|
|
}
|
|
|
|
fileprivate func _prepareCall(apiCall: ApiCall<T>) throws {
|
|
apiCall.lastAttemptDate = Date()
|
|
apiCall.attemptsCount += 1
|
|
try self.apiCallsCollection?.addOrUpdate(instance: apiCall)
|
|
}
|
|
|
|
/// Sends an insert api call for the provided [instance]
|
|
fileprivate func _sendInsertionIfNecessary(_ instance: T) throws {
|
|
guard self.synchronized else {
|
|
return
|
|
}
|
|
|
|
if let apiCall = try self._callForInstance(instance, method: Method.post) {
|
|
try self._prepareCall(apiCall: apiCall)
|
|
|
|
Task {
|
|
do {
|
|
_ = try await self._store.execute(apiCall: apiCall)
|
|
} catch {
|
|
self.rescheduleApiCallsIfNecessary()
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/// Sends an update api call for the provided [instance]
|
|
fileprivate func _sendUpdateIfNecessary(_ instance: T) throws {
|
|
guard self.synchronized else {
|
|
return
|
|
}
|
|
|
|
if let apiCall = try self._callForInstance(instance, method: Method.put) {
|
|
try self._prepareCall(apiCall: apiCall)
|
|
Task {
|
|
do {
|
|
_ = try await self._store.execute(apiCall: apiCall)
|
|
} catch {
|
|
Logger.error(error)
|
|
self.rescheduleApiCallsIfNecessary()
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Task {
|
|
// do {
|
|
// if let apiCall = try self._callForInstance(instance, method: Method.put) {
|
|
// try self._prepareCall(apiCall: apiCall)
|
|
// _ = try await self._store.execute(apiCall: apiCall)
|
|
// }
|
|
//
|
|
//// let _ = try await self._store.service?.update(instance)
|
|
// } catch {
|
|
// Logger.error(error)
|
|
// self.rescheduleApiCallsIfNecessary()
|
|
// }
|
|
// }
|
|
|
|
}
|
|
|
|
/// Sends an delete api call for the provided [instance]
|
|
fileprivate func _sendDeletionIfNecessary(_ instance: T) throws {
|
|
guard self.synchronized else {
|
|
return
|
|
}
|
|
|
|
if let apiCall = try self._callForInstance(instance, method: Method.delete) {
|
|
try self._prepareCall(apiCall: apiCall)
|
|
Task {
|
|
do {
|
|
_ = try await self._store.execute(apiCall: apiCall)
|
|
} catch {
|
|
Logger.error(error)
|
|
self.rescheduleApiCallsIfNecessary()
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
// MARK: - Reschedule calls
|
|
|
|
/// number of time an execution loop has been called
|
|
fileprivate var _attemptLoops: Int = 0
|
|
|
|
/// Indicates if the collection is currently retrying ApiCalls
|
|
fileprivate var _isRetryingCalls: Bool = false
|
|
|
|
func rescheduleApiCallsIfNecessary() {
|
|
if !self._isRetryingCalls {
|
|
self._rescheduleApiCalls()
|
|
}
|
|
}
|
|
|
|
fileprivate func _rescheduleApiCalls() {
|
|
|
|
guard let apiCallsCollection, apiCallsCollection.isNotEmpty else {
|
|
return
|
|
}
|
|
|
|
self._isRetryingCalls = true
|
|
self._attemptLoops += 1
|
|
|
|
Task {
|
|
|
|
let delay = pow(2, self._attemptLoops)
|
|
let seconds = NSDecimalNumber(decimal: delay).intValue
|
|
Logger.log("wait for \(seconds) sec")
|
|
try await Task.sleep(until: .now + .seconds(seconds))
|
|
|
|
let apiCallsCopy = apiCallsCollection.items
|
|
for apiCall in apiCallsCopy {
|
|
apiCall.attemptsCount += 1
|
|
apiCall.lastAttemptDate = Date()
|
|
do {
|
|
let _ = try await Store.main.execute(apiCall: apiCall)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
|
|
if apiCallsCollection.isEmpty {
|
|
self._isRetryingCalls = false
|
|
} else {
|
|
self._rescheduleApiCalls()
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
func deleteApiCallById(_ id: String) throws {
|
|
guard let apiCallsCollection else {
|
|
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName())
|
|
}
|
|
try apiCallsCollection.deleteById(id)
|
|
}
|
|
|
|
// 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)
|
|
// }
|
|
|
|
}
|
|
|