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.
298 lines
10 KiB
298 lines
10 KiB
//
|
|
// Store.swift
|
|
// LeStorage
|
|
//
|
|
// Created by Laurent Morvillier on 02/02/2024.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
|
|
//public enum ResetOption {
|
|
// case all
|
|
// case synchronizedOnly
|
|
//}
|
|
|
|
public enum StoreError: Error {
|
|
case missingService
|
|
case missingUserId
|
|
case unexpectedCollectionType(name: String)
|
|
case apiCallCollectionNotRegistered(type: String)
|
|
case collectionNotRegistered(type: String)
|
|
case cannotSyncCollection(name: String)
|
|
}
|
|
|
|
public struct StoreIdentifier {
|
|
var value: String
|
|
var parameterName: String
|
|
|
|
public init(value: String, parameterName: String) {
|
|
self.value = value
|
|
self.parameterName = parameterName
|
|
}
|
|
|
|
var urlComponent: String {
|
|
return "?\(self.parameterName)=\(self.value)"
|
|
}
|
|
}
|
|
|
|
open class Store {
|
|
|
|
/// The Store singleton
|
|
public static let main = Store()
|
|
|
|
/// The dictionary of registered StoredCollections
|
|
fileprivate var _collections: [String : any SomeCollection] = [:]
|
|
|
|
/// The name of the directory to store the json files
|
|
static let storageDirectory = "storage"
|
|
|
|
/// The store identifier, used to name the store directory, and to perform filtering requests to the server
|
|
fileprivate(set) var identifier: StoreIdentifier? = nil
|
|
|
|
/// Indicates whether the store directory has been created at the init
|
|
fileprivate var _created: Bool = false
|
|
|
|
public init() {
|
|
self._createDirectory(directory: Store.storageDirectory)
|
|
}
|
|
|
|
public required init(identifier: String, parameter: String) {
|
|
self.identifier = StoreIdentifier(value: identifier, parameterName: parameter)
|
|
let directory = "\(Store.storageDirectory)/\(identifier)"
|
|
self._createDirectory(directory: directory)
|
|
}
|
|
|
|
/// Creates the store directory
|
|
/// - Parameters:
|
|
/// - directory: the name of the directory
|
|
fileprivate func _createDirectory(directory: String) {
|
|
self._created = FileManager.default.createDirectoryInDocuments(directoryName: directory)
|
|
}
|
|
|
|
/// A method to provide ids corresponding to the django storage
|
|
public static func randomId() -> String {
|
|
return UUID().uuidString.lowercased()
|
|
}
|
|
|
|
/// Registers a collection
|
|
/// - Parameters:
|
|
/// - synchronized: indicates if the data is synchronized with the server
|
|
/// - indexed: Creates an index to quickly access the data
|
|
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
|
|
/// - sendsUpdate: Indicates if updates of items should be sent to the server
|
|
public func registerCollection<T : Storable>(synchronized: Bool, indexed: Bool = false, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredCollection<T> {
|
|
|
|
// register collection
|
|
let collection = StoredCollection<T>(synchronized: synchronized, store: self, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate)
|
|
self._collections[T.resourceName()] = collection
|
|
|
|
if synchronized {
|
|
StoreCenter.main.loadApiCallCollection(type: T.self)
|
|
}
|
|
|
|
if self._created, let identifier {
|
|
self._migrate(collection, identifier: identifier, type: T.self)
|
|
}
|
|
|
|
return collection
|
|
}
|
|
|
|
/// Registers a singleton object
|
|
/// - Parameters:
|
|
/// - synchronized: indicates if the data is synchronized with the server
|
|
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
|
|
/// - sendsUpdate: Indicates if updates of items should be sent to the server
|
|
public func registerObject<T : Storable>(synchronized: Bool, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredSingleton<T> {
|
|
|
|
let storedObject = StoredSingleton<T>(synchronized: synchronized, store: self, inMemory: inMemory, sendsUpdate: sendsUpdate)
|
|
self._collections[T.resourceName()] = storedObject
|
|
return storedObject
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
/// Looks for an instance by id
|
|
/// - Parameters:
|
|
/// - id: the id of the data
|
|
public func findById<T: Storable>(_ id: String) -> T? {
|
|
guard let collection = self._collections[T.resourceName()] as? StoredCollection<T> else {
|
|
Logger.w("Collection \(T.resourceName()) not registered")
|
|
return nil
|
|
}
|
|
return collection.findById(id)
|
|
}
|
|
|
|
/// Filters a collection by predicate
|
|
/// - Parameters:
|
|
/// - isIncluded: a predicate to returns if a data should be filtered in
|
|
public func filter<T: Storable>(isIncluded: (T) throws -> (Bool)) rethrows -> [T] {
|
|
do {
|
|
return try self.collection().filter(isIncluded)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/// Returns a collection by type
|
|
func collection<T: Storable>() throws -> StoredCollection<T> {
|
|
if let collection = self._collections[T.resourceName()] as? StoredCollection<T> {
|
|
return collection
|
|
}
|
|
throw StoreError.collectionNotRegistered(type: T.resourceName())
|
|
}
|
|
|
|
/// Loads all collection with the data from the server
|
|
public func loadCollectionsFromServer() {
|
|
for collection in self._collections.values {
|
|
if collection.synchronized {
|
|
Task {
|
|
try? await collection.loadDataFromServerIfAllowed()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resets all registered collection
|
|
public func reset() {
|
|
for collection in self._collections.values {
|
|
collection.reset()
|
|
}
|
|
}
|
|
|
|
/// Returns the names of all collections
|
|
public func collectionNames() -> [String] {
|
|
return self._collections.values.map { $0.resourceName }
|
|
}
|
|
|
|
// MARK: - Write
|
|
|
|
/// Returns the directory URL of the store
|
|
fileprivate func _directoryPath() throws -> URL {
|
|
var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
|
|
if let identifier = self.identifier?.value {
|
|
url.append(component: identifier)
|
|
}
|
|
return url
|
|
}
|
|
|
|
/// Writes some content into a file inside the Store directory
|
|
/// - Parameters:
|
|
/// - content: the content to write
|
|
/// - fileName: the name of the file
|
|
func write(content: String, fileName: String) throws {
|
|
var fileURL = try self._directoryPath()
|
|
fileURL.append(component: fileName)
|
|
try content.write(to: fileURL, atomically: false, encoding: .utf8)
|
|
}
|
|
|
|
/// Returns the URL matching a Storable type
|
|
/// - Parameters:
|
|
/// - type: a Storable type
|
|
func fileURL<T: Storable>(type: T.Type) throws -> URL {
|
|
let fileURL = try self._directoryPath()
|
|
return fileURL.appending(component: T.fileName())
|
|
}
|
|
|
|
/// Removes a file matching a Storable type
|
|
/// - Parameters:
|
|
/// - type: a Storable type
|
|
func removeFile<T: Storable>(type: T.Type) {
|
|
do {
|
|
let url: URL = try self.fileURL(type: type)
|
|
if FileManager.default.fileExists(atPath: url.path()) {
|
|
try FileManager.default.removeItem(at: url)
|
|
}
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
|
|
/// Retrieves all the items on the server
|
|
public func getItems<T: Storable>() async throws -> [T] {
|
|
if T.filterByStoreIdentifier() {
|
|
return try await StoreCenter.main.getItems(identifier: self.identifier)
|
|
} else {
|
|
return try await StoreCenter.main.getItems()
|
|
}
|
|
}
|
|
|
|
/// Requests an insertion to the StoreCenter
|
|
/// - Parameters:
|
|
/// - instance: an object to insert
|
|
func sendInsertion<T: Storable>(_ instance: T) async throws {
|
|
try await StoreCenter.main.sendInsertion(instance)
|
|
}
|
|
|
|
/// Requests an update to the StoreCenter
|
|
/// - Parameters:
|
|
/// - instance: an object to update
|
|
func sendUpdate<T: Storable>(_ instance: T) async throws {
|
|
try await StoreCenter.main.sendUpdate(instance)
|
|
}
|
|
|
|
/// Requests a deletion to the StoreCenter
|
|
/// - Parameters:
|
|
/// - instance: an object to delete
|
|
func sendDeletion<T: Storable>(_ instance: T) async throws {
|
|
try await StoreCenter.main.sendDeletion(instance)
|
|
}
|
|
|
|
public func loadCollectionsFromServerIfNoFile() {
|
|
for collection in self._collections.values {
|
|
// Logger.log("Load \(name)")
|
|
if collection.synchronized {
|
|
Task {
|
|
do {
|
|
try await collection.loadCollectionsFromServerIfNoFile()
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func collectionsAllLoaded() -> Bool {
|
|
return self._collections.values.allSatisfy { $0.hasLoadedLocally }
|
|
}
|
|
|
|
fileprivate var _validIds: [String] = []
|
|
|
|
fileprivate func _migrate<T : Storable>(_ collection: StoredCollection<T>, identifier: StoreIdentifier, type: T.Type) {
|
|
|
|
self._validIds.append(identifier.value)
|
|
|
|
let oldCollection: StoredCollection<T> = StoredCollection<T>(synchronized: false, store: Store.main, asynchronousIO: false)
|
|
|
|
let filtered: [T] = oldCollection.items.filter { item in
|
|
var propertyValue: String? = item.stringForPropertyName(identifier.parameterName)
|
|
if propertyValue == nil {
|
|
let values = T.relationshipNames.map { item.stringForPropertyName($0) }
|
|
propertyValue = values.compactMap { $0 }.first
|
|
}
|
|
return self._validIds.first(where: { $0 == propertyValue }) != nil
|
|
}
|
|
|
|
if filtered.count > 0 {
|
|
self._validIds.append(contentsOf: filtered.map { $0.stringId })
|
|
try? collection.addOrUpdateNoSync(contentOfs: filtered)
|
|
Logger.log("Migrated \(filtered.count) \(T.resourceName())")
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
fileprivate extension Storable {
|
|
|
|
func stringForPropertyName(_ propertyName: String) -> String? {
|
|
let mirror = Mirror(reflecting: self)
|
|
for child in mirror.children {
|
|
if let label = child.label, label == "_\(propertyName)" {
|
|
return child.value as? String
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
}
|
|
|