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

616 lines
20 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 {
var resourceName: String { get }
var synchronized: Bool { get }
func allItems() -> [any Storable]
func deleteById(_ id: String) throws
func deleteApiCallById(_ id: String) throws
func loadDataFromServerIfAllowed() async throws
func hasPendingAPICalls() -> Bool
func contentOfApiCallFile() -> String?
func reset()
func resetApiCalls()
func apiCallById(_ id: String) throws -> (any SomeCall)?
}
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
/// Doesn't write the collection in a file
fileprivate var _inMemory: Bool = false
/// Indicates if the synchronized collection sends update to the API
fileprivate var _sendsUpdate: Bool = true
/// 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 _indexes: [String : T]? = nil
/// Collection of API calls used to store HTTP calls
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 asynchronously
fileprivate var asynchronousIO: Bool = true
/// Indicates if the collection has loaded objects from the server
fileprivate(set) public var hasLoadedFromServer: Bool = false
init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true, loadCompletion: ((StoredCollection<T>) -> ())? = nil) {
self.synchronized = synchronized
self.asynchronousIO = asynchronousIO
if indexed {
self._indexes = [:]
}
self._inMemory = inMemory
self._sendsUpdate = sendsUpdate
self._store = store
self.loadCompletion = loadCompletion
if synchronized {
self.apiCallsCollection = StoredCollection<ApiCall<T>>(synchronized: false, store: store, loadCompletion: { apiCallCollection in
self._rescheduleApiCalls()
})
}
self._load()
}
var resourceName: String {
return T.resourceName()
}
// MARK: - Paths
fileprivate func _storageDirectoryPath() throws -> URL {
return try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
}
fileprivate func _writeToStorageDirectory(content: String, fileName: String) throws {
var fileURL = try self._storageDirectoryPath()
fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
}
fileprivate func _urlForJSONFile() throws -> URL {
var storageDirectory = try self._storageDirectoryPath()
storageDirectory.append(component: T.fileName())
return storageDirectory
}
// MARK: - Loading
/// Migrates if necessary and asynchronously decodes the json file
fileprivate func _load() {
do {
if self._inMemory {
Task {
try await self.loadDataFromServerIfAllowed()
}
} else {
try self._loadFromFile()
}
} catch {
Logger.error(error)
}
}
/// Starts the JSON file decoding synchronously or asynchronously
fileprivate func _loadFromFile() throws {
if self.asynchronousIO {
Task(priority: .high) {
try self._decodeJSONFile()
}
} else {
try self._decodeJSONFile()
}
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws {
let fileURL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
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)
}
} else {
DispatchQueue.main.async {
// Logger.log("collection \(T.fileName()) has no file yet")
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._indexes {
self._indexes = self.items.dictionary { $0.stringId }
}
}
/// Retrieves the data from the server and loads it into the items array
public func loadDataFromServerIfAllowed() async throws {
guard self.synchronized, !(self is StoredSingleton<T>) else {
throw StoreError.unSynchronizedCollection
}
do {
let items: [T] = try await self._store.getItems()
try self._addOrUpdate(contentOfs: items, shouldSync: false)
// self._hasChanged = true
self.hasLoadedFromServer = true
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
} 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)
try self._sendInsertionIfNecessary(instance)
}
self._indexes?[instance.stringId] = instance
}
public func writeChangeAndInsertOnServer(instance: T) throws {
defer {
self._hasChanged = true
}
try self._sendInsertionIfNecessary(instance)
}
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer {
self._hasChanged = true
}
self.items.removeAll()
self.items.append(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._indexes?.removeValue(forKey: instance.stringId)
try self._sendDeletionIfNecessary(instance)
}
/// Deletes all items of the sequence by id
public func delete(contentOfs sequence: any Sequence<T>) throws {
defer {
self._hasChanged = true
}
for instance in sequence {
try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId)
try self._sendDeletionIfNecessary(instance)
}
}
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) throws {
try self._addOrUpdate(contentOfs: sequence)
}
/// Inserts or updates all items in the sequence
fileprivate func _addOrUpdate(contentOfs sequence: any Sequence<T>, shouldSync: Bool = true) throws {
defer {
self._hasChanged = true
}
for instance in sequence {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance
if shouldSync {
try self._sendUpdateIfNecessary(instance)
}
} else { // insert
self.items.append(instance)
if shouldSync {
try self._sendInsertionIfNecessary(instance)
}
}
self._indexes?[instance.stringId] = instance
}
}
/// Returns the instance corresponding to the provided [id]
public func findById(_ id: String) -> T? {
if let index = self._indexes, 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
/// Also removes related API calls
public func deleteDependencies(_ items: any Sequence<T>) {
defer {
self._hasChanged = true
}
for item in items {
self.items.removeAll(where: { $0.id == item.id })
/// remove related API call if existing
if let apiCallIndex = self.apiCallsCollection?.firstIndex(where: { $0.dataId == item.id }) {
self.apiCallsCollection?.items.remove(at: apiCallIndex)
}
}
}
/// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls
public func deleteAll() throws {
try self.delete(contentOfs: self.items)
}
// 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() {
guard !self._inMemory else { return }
if self.asynchronousIO {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // 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 to \(T.fileName())...")
do {
let jsonString: String = try self.items.jsonString()
try self._writeToStorageDirectory(content: jsonString, fileName: T.fileName())
// let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: T.fileName())
} catch {
Logger.error(error) // TODO how to notify the main project
}
Logger.log("End write")
}
/// Simply clears the items of the collection
func clear() {
self.items.removeAll()
}
/// Removes the items of the collection, deletes the corresponding file, and also reset the related API calls collection
public func reset() {
self.items.removeAll()
do {
let url: URL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: url.path()) {
try FileManager.default.removeItem(at: url)
}
} catch {
Logger.error(error)
}
self.resetApiCalls()
}
/// Removes the collection related API calls collection
public func resetApiCalls() {
if let apiCallsCollection = self.apiCallsCollection {
apiCallsCollection.reset()
}
}
// MARK: - Synchronization
/// Returns an APICall instance for the Storable [instance] and an HTTP [method]
/// The method updates existing calls or creates a new one
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? {
guard let apiCallCollection = self.apiCallsCollection else {
throw StoredCollectionError.missingApiCallCollection
}
if let existingCall = apiCallCollection.first(where: { $0.dataId == instance.id }) {
switch method {
case .delete:
try self.deleteApiCallById(existingCall.id) // delete the existing call as we don't need it
if existingCall.method == HTTPMethod.post {
return nil // if the post has not been done, we can just stop here
} else {
return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete
}
default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values
existingCall.body = try instance.jsonString()
return existingCall
}
} else {
return try self._createCall(instance, method: method)
}
}
/// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> {
let jsonString = try instance.jsonString()
return ApiCall(method: method, dataId: String(instance.id), body: jsonString)
}
/// Prepares a call for execution by updating its properties and adding it to its collection for storage
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, Store.main.collectionsCanSynchronize else {
return
}
Task {
do {
try await self._synchronize(instance, method: HTTPMethod.post)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
}
/// Sends an update api call for the provided [instance]
fileprivate func _sendUpdateIfNecessary(_ instance: T) throws {
guard self.synchronized, self._sendsUpdate, Store.main.collectionsCanSynchronize else {
return
}
Task {
do {
try await self._synchronize(instance, method: HTTPMethod.put)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
}
/// Sends an delete api call for the provided [instance]
fileprivate func _sendDeletionIfNecessary(_ instance: T) throws {
guard self.synchronized, Store.main.collectionsCanSynchronize else {
return
}
Task {
do {
try await self._synchronize(instance, method: HTTPMethod.delete)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
}
fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws {
if let apiCall = try self._callForInstance(instance, method: method) {
try self._prepareCall(apiCall: apiCall)
try await self._executeApiCall(apiCall)
}
}
fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws {
let result = try await self._store.execute(apiCall: apiCall)
switch apiCall.method {
case .post:
// DispatchQueue.main.async {
if let instance = self.findById(result.stringId) {
self._hasChanged = instance.copyFromServerInstance(result)
}
// }
default:
break
}
Logger.log("")
}
// 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
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() {
if !self._isRetryingCalls {
self._rescheduleApiCalls()
}
}
/// Reschedule API calls
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 {
try await self._executeApiCall(apiCall)
// let _ = try await Store.main.execute(apiCall: apiCall)
} catch {
Logger.error(error)
}
}
if apiCallsCollection.isEmpty {
self._isRetryingCalls = false
} else {
self._rescheduleApiCalls()
}
}
}
/// Deletes an API call by [id]
func deleteApiCallById(_ id: String) throws {
guard let apiCallsCollection else {
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName())
}
try apiCallsCollection.deleteById(id)
}
func apiCallById(_ id: String) throws -> (any SomeCall)? {
guard let apiCallsCollection else {
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName())
}
return apiCallsCollection.findById(id)
}
/// Returns if the API call collection is not empty
func hasPendingAPICalls() -> Bool {
guard let apiCallsCollection else { return false }
return apiCallsCollection.isNotEmpty
}
func contentOfApiCallFile() -> String? {
guard let fileURL = try? self.apiCallsCollection?._urlForJSONFile() else { return nil }
if FileManager.default.fileExists(atPath: fileURL.path()) {
return try? FileUtils.readFile(fileURL: fileURL)
}
return nil
}
// 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
}
}
}