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.
493 lines
15 KiB
493 lines
15 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 CollectionHolder {
|
|
associatedtype Item
|
|
|
|
var items: [Item] { get }
|
|
func reset()
|
|
}
|
|
|
|
protocol SomeCollection: CollectionHolder, Identifiable {
|
|
var resourceName: String { get }
|
|
var synchronized: Bool { get }
|
|
var hasLoaded: Bool { get }
|
|
|
|
func allItems() -> [any Storable]
|
|
|
|
func loadDataFromServerIfAllowed() async throws
|
|
func loadCollectionsFromServerIfNoFile() async 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, CollectionHolder {
|
|
|
|
/// 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
|
|
|
|
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
|
|
fileprivate var _indexes: [T.ID : 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 locally, with or without a file
|
|
fileprivate(set) public var hasLoaded: Bool = false
|
|
|
|
init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true) {
|
|
self.synchronized = synchronized
|
|
self.asynchronousIO = asynchronousIO
|
|
if indexed {
|
|
self._indexes = [:]
|
|
}
|
|
self._inMemory = inMemory
|
|
self._sendsUpdate = sendsUpdate
|
|
self._store = store
|
|
|
|
self._load()
|
|
}
|
|
|
|
fileprivate init() {
|
|
self.synchronized = false
|
|
self._store = Store.main
|
|
}
|
|
|
|
public static func placeholder() -> StoredCollection<T> {
|
|
return StoredCollection<T>()
|
|
}
|
|
|
|
var resourceName: String {
|
|
return T.resourceName()
|
|
}
|
|
|
|
// 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._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() ?? []
|
|
for var item in decoded {
|
|
item.store = self._store
|
|
}
|
|
if self.asynchronousIO {
|
|
DispatchQueue.main.async {
|
|
self._setItems(decoded)
|
|
self._setAsLoaded()
|
|
}
|
|
} else {
|
|
self._setItems(decoded)
|
|
self._setAsLoaded()
|
|
}
|
|
} else {
|
|
self._setAsLoaded()
|
|
}
|
|
}
|
|
|
|
/// Sets the collection as loaded
|
|
/// Send a CollectionDidLoad event
|
|
fileprivate func _setAsLoaded() {
|
|
self.hasLoaded = true
|
|
DispatchQueue.main.async {
|
|
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
|
|
}
|
|
}
|
|
|
|
/// Sets a collection of items and indexes them
|
|
fileprivate func _setItems(_ items: [T]) {
|
|
self.items = items
|
|
self._updateIndexIfNecessary()
|
|
}
|
|
|
|
/// Updates the whole index with the items array
|
|
fileprivate func _updateIndexIfNecessary() {
|
|
if let _ = self._indexes {
|
|
self._indexes = self.items.dictionary { $0.id }
|
|
}
|
|
}
|
|
|
|
/// 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.cannotSyncCollection(name: self.resourceName)
|
|
}
|
|
do {
|
|
let items: [T] = try await self._store.getItems()
|
|
if items.count > 0 {
|
|
DispatchQueue.main.async {
|
|
self._addOrUpdate(contentOfs: items, shouldSync: false)
|
|
}
|
|
}
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
self._setAsLoaded()
|
|
}
|
|
|
|
/// Loads the collection using the server data only if the collection file doesn't exists
|
|
func loadCollectionsFromServerIfNoFile() async throws {
|
|
let fileURL: URL = try self._store.fileURL(type: T.self)
|
|
if !FileManager.default.fileExists(atPath: fileURL.path()) {
|
|
try await self.loadDataFromServerIfAllowed()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
var item = instance
|
|
item.store = self._store
|
|
|
|
// 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._sendInsertionIfNecessary(instance)
|
|
}
|
|
self._indexes?[instance.id] = instance
|
|
|
|
}
|
|
|
|
/// Sends a POST request for the instance, and changes the collection to perform a write
|
|
public func writeChangeAndInsertOnServer(instance: T) {
|
|
defer {
|
|
self._hasChanged = true
|
|
}
|
|
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.id)
|
|
|
|
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.id)
|
|
self._sendDeletionIfNecessary(instance)
|
|
}
|
|
}
|
|
|
|
/// Adds or update a sequence of elements
|
|
public func addOrUpdate(contentOfs sequence: any Sequence<T>) throws {
|
|
self._addOrUpdate(contentOfs: sequence)
|
|
}
|
|
|
|
/// Adds or update a sequence of elements without synchronizing it
|
|
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) throws {
|
|
self._addOrUpdate(contentOfs: sequence, shouldSync: false)
|
|
}
|
|
|
|
/// Inserts or updates all items in the sequence
|
|
fileprivate func _addOrUpdate(contentOfs sequence: any Sequence<T>, shouldSync: Bool = true) {
|
|
defer {
|
|
self._hasChanged = true
|
|
}
|
|
|
|
for var instance in sequence {
|
|
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
|
|
self.items[index] = instance
|
|
if shouldSync {
|
|
self._sendUpdateIfNecessary(instance)
|
|
}
|
|
} else { // insert
|
|
self.items.append(instance)
|
|
if shouldSync {
|
|
self._sendInsertionIfNecessary(instance)
|
|
}
|
|
}
|
|
instance.store = self._store
|
|
self._indexes?[instance.id] = instance
|
|
}
|
|
|
|
}
|
|
|
|
/// 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 })
|
|
}
|
|
|
|
/// Deletes the instance corresponding to the provided [id]
|
|
public func deleteById(_ id: T.ID) 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 {
|
|
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
|
|
self.items.remove(at: index)
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// 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: - Migrations
|
|
|
|
/// Makes POST ApiCall for all items in the collection
|
|
public func insertAllIntoCurrentService() {
|
|
for item in self.items {
|
|
self._sendInsertionIfNecessary(item)
|
|
}
|
|
}
|
|
|
|
/// Makes POST ApiCall for the provided item
|
|
public func insertIntoCurrentService(item: T) {
|
|
self._sendInsertionIfNecessary(item)
|
|
}
|
|
|
|
// 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._store.write(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 and deletes the corresponding file
|
|
public func reset() {
|
|
self.items.removeAll()
|
|
self._store.removeFile(type: T.self)
|
|
}
|
|
|
|
// MARK: - Reschedule calls
|
|
|
|
/// Sends an insert api call for the provided
|
|
/// Calls copyFromServerInstance on the instance with the result of the HTTP call
|
|
/// - Parameters:
|
|
/// - instance: the object to POST
|
|
fileprivate func _sendInsertionIfNecessary(_ instance: T) {
|
|
guard self.synchronized else {
|
|
return
|
|
}
|
|
Task {
|
|
do {
|
|
if let result = try await self._store.sendInsertion(instance) {
|
|
DispatchQueue.main.async {
|
|
self._hasChanged = instance.copyFromServerInstance(result)
|
|
}
|
|
}
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sends an update api call for the provided [instance]
|
|
/// - Parameters:
|
|
/// - instance: the object to PUT
|
|
fileprivate func _sendUpdateIfNecessary(_ instance: T) {
|
|
guard self.synchronized, self._sendsUpdate else {
|
|
return
|
|
}
|
|
Task {
|
|
do {
|
|
try await self._store.sendUpdate(instance)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sends an delete api call for the provided [instance]
|
|
/// - Parameters:
|
|
/// - instance: the object to DELETE
|
|
fileprivate func _sendDeletionIfNecessary(_ instance: T) {
|
|
guard self.synchronized else {
|
|
return
|
|
}
|
|
Task {
|
|
do {
|
|
try await self._store.sendDeletion(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
|
|
}
|
|
}
|
|
|
|
}
|
|
|