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

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
}
}