Compare commits
171 Commits
@ -0,0 +1,55 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<Scheme |
||||||
|
LastUpgradeVersion = "1630" |
||||||
|
version = "1.7"> |
||||||
|
<BuildAction |
||||||
|
parallelizeBuildables = "YES" |
||||||
|
buildImplicitDependencies = "YES" |
||||||
|
buildArchitectures = "Automatic"> |
||||||
|
</BuildAction> |
||||||
|
<TestAction |
||||||
|
buildConfiguration = "Debug" |
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES" |
||||||
|
shouldAutocreateTestPlan = "YES"> |
||||||
|
<Testables> |
||||||
|
<TestableReference |
||||||
|
skipped = "NO" |
||||||
|
parallelizable = "YES"> |
||||||
|
<BuildableReference |
||||||
|
BuildableIdentifier = "primary" |
||||||
|
BlueprintIdentifier = "C4C33F6A2C9B06B7006316DE" |
||||||
|
BuildableName = "LeStorageTests.xctest" |
||||||
|
BlueprintName = "LeStorageTests" |
||||||
|
ReferencedContainer = "container:LeStorage.xcodeproj"> |
||||||
|
</BuildableReference> |
||||||
|
</TestableReference> |
||||||
|
</Testables> |
||||||
|
</TestAction> |
||||||
|
<LaunchAction |
||||||
|
buildConfiguration = "Debug" |
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||||
|
launchStyle = "0" |
||||||
|
useCustomWorkingDirectory = "NO" |
||||||
|
ignoresPersistentStateOnLaunch = "NO" |
||||||
|
debugDocumentVersioning = "YES" |
||||||
|
debugServiceExtension = "internal" |
||||||
|
allowLocationSimulation = "YES"> |
||||||
|
</LaunchAction> |
||||||
|
<ProfileAction |
||||||
|
buildConfiguration = "Release" |
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES" |
||||||
|
savedToolIdentifier = "" |
||||||
|
useCustomWorkingDirectory = "NO" |
||||||
|
debugDocumentVersioning = "YES"> |
||||||
|
</ProfileAction> |
||||||
|
<AnalyzeAction |
||||||
|
buildConfiguration = "Debug"> |
||||||
|
</AnalyzeAction> |
||||||
|
<ArchiveAction |
||||||
|
buildConfiguration = "Release" |
||||||
|
revealArchiveInOrganizer = "YES"> |
||||||
|
</ArchiveAction> |
||||||
|
</Scheme> |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
|
||||||
|
### Le Storage |
||||||
|
|
||||||
|
LeStorage is used to store objects into json files, and it can also be used to synchronize those objects to a django server properly configured. |
||||||
|
|
||||||
|
Here are the most important classes: |
||||||
|
- StoredCollection: stores object of one class in a json file |
||||||
|
- SyncedCollection: stores object of one class in a json file and synchronizes changes with the server |
||||||
|
- ApiCallCollection: provision HTTP calls and tries to execute them again |
||||||
|
- StoreCenter: The central class to manages all collections through Store instances |
||||||
|
|
||||||
@ -0,0 +1,83 @@ |
|||||||
|
// |
||||||
|
// DataAcces.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 21/11/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
class DataAccess: SyncedModelObject, SyncedStorable { |
||||||
|
|
||||||
|
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } |
||||||
|
static func resourceName() -> String { return "data-access" } |
||||||
|
static func relationships() -> [Relationship] { return [] } |
||||||
|
public static func parentRelationships() -> [Relationship] { return [] } |
||||||
|
public static func childrenRelationships() -> [Relationship] { return [] } |
||||||
|
|
||||||
|
static var copyServerResponse: Bool = false |
||||||
|
static func storeParent() -> Bool { return false } |
||||||
|
|
||||||
|
override required init() { |
||||||
|
super.init() |
||||||
|
} |
||||||
|
|
||||||
|
var id: String = Store.randomId() |
||||||
|
var sharedWith: [String] = [] |
||||||
|
var modelName: String = "" |
||||||
|
var modelId: String = "" |
||||||
|
var grantedAt: Date = Date() |
||||||
|
|
||||||
|
init(owner: String, sharedWith: [String], modelName: String, modelId: String, storeId: String?) { |
||||||
|
self.sharedWith = sharedWith |
||||||
|
self.modelName = modelName |
||||||
|
self.modelId = modelId |
||||||
|
super.init() |
||||||
|
self.relatedUser = owner |
||||||
|
self.storeId = storeId |
||||||
|
} |
||||||
|
|
||||||
|
// Codable implementation |
||||||
|
enum CodingKeys: String, CodingKey { |
||||||
|
case id |
||||||
|
case sharedWith |
||||||
|
case modelName |
||||||
|
case modelId |
||||||
|
case grantedAt |
||||||
|
} |
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws { |
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self) |
||||||
|
id = try container.decode(String.self, forKey: .id) |
||||||
|
sharedWith = try container.decode([String].self, forKey: .sharedWith) |
||||||
|
modelName = try container.decode(String.self, forKey: .modelName) |
||||||
|
modelId = try container.decode(String.self, forKey: .modelId) |
||||||
|
grantedAt = try container.decode(Date.self, forKey: .grantedAt) |
||||||
|
try super.init(from: decoder) |
||||||
|
} |
||||||
|
|
||||||
|
override func encode(to encoder: Encoder) throws { |
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self) |
||||||
|
try container.encode(id, forKey: .id) |
||||||
|
try container.encode(sharedWith, forKey: .sharedWith) |
||||||
|
try container.encode(modelName, forKey: .modelName) |
||||||
|
try container.encode(modelId, forKey: .modelId) |
||||||
|
try container.encode(grantedAt, forKey: .grantedAt) |
||||||
|
try super.encode(to: encoder) |
||||||
|
} |
||||||
|
|
||||||
|
func copy(from other: any Storable) { |
||||||
|
guard let dataAccess = other as? DataAccess else { return } |
||||||
|
self.id = dataAccess.id |
||||||
|
self.sharedWith = dataAccess.sharedWith |
||||||
|
self.modelName = dataAccess.modelName |
||||||
|
self.modelId = dataAccess.modelId |
||||||
|
self.storeId = dataAccess.storeId |
||||||
|
self.grantedAt = dataAccess.grantedAt |
||||||
|
} |
||||||
|
|
||||||
|
public func copyForUpdate(from other: any Storable) { |
||||||
|
self.copy(from: other) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
// |
||||||
|
// DataLog.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 11/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
class DataLog: ModelObject, Storable { |
||||||
|
|
||||||
|
static func resourceName() -> String { return "data-logs" } |
||||||
|
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } |
||||||
|
static func relationships() -> [Relationship] { return [] } |
||||||
|
public static func parentRelationships() -> [Relationship] { return [] } |
||||||
|
public static func childrenRelationships() -> [Relationship] { return [] } |
||||||
|
|
||||||
|
static func storeParent() -> Bool { return false } |
||||||
|
|
||||||
|
var id: String = Store.randomId() |
||||||
|
|
||||||
|
/// The id of the underlying data |
||||||
|
var dataId: String |
||||||
|
|
||||||
|
/// The name of class of the underlying data |
||||||
|
var modelName: String |
||||||
|
|
||||||
|
/// The operation performed on the underlying data |
||||||
|
var operation: HTTPMethod |
||||||
|
|
||||||
|
init(dataId: String, modelName: String, operation: HTTPMethod) { |
||||||
|
self.dataId = dataId |
||||||
|
self.modelName = modelName |
||||||
|
self.operation = operation |
||||||
|
} |
||||||
|
|
||||||
|
func copy(from other: any Storable) { |
||||||
|
fatalError("should not happen") |
||||||
|
} |
||||||
|
public func copyForUpdate(from other: any Storable) { |
||||||
|
fatalError("should not happen") |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
// |
||||||
|
// SyncData.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 18/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible { |
||||||
|
|
||||||
|
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } |
||||||
|
static var copyServerResponse: Bool = false |
||||||
|
static func storeParent() -> Bool { return false } |
||||||
|
|
||||||
|
var date: String = "" |
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey { |
||||||
|
case date |
||||||
|
} |
||||||
|
|
||||||
|
override required init() { |
||||||
|
super.init() |
||||||
|
} |
||||||
|
|
||||||
|
required public init(from decoder: Decoder) throws { |
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self) |
||||||
|
date = try container.decode(String.self, forKey: .date) |
||||||
|
try super.init(from: decoder) |
||||||
|
} |
||||||
|
|
||||||
|
static func resourceName() -> String { |
||||||
|
return "sync-data" |
||||||
|
} |
||||||
|
|
||||||
|
func copy(from other: any Storable) { |
||||||
|
guard let getSyncData = other as? GetSyncData else { return } |
||||||
|
self.date = getSyncData.date |
||||||
|
} |
||||||
|
public func copyForUpdate(from other: any Storable) { |
||||||
|
fatalError("should not happen") |
||||||
|
} |
||||||
|
func queryParameters(storeCenter: StoreCenter) -> [String : String] { |
||||||
|
return ["last_update" : self._formattedLastUpdate, |
||||||
|
"device_id" : storeCenter.deviceId()] |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate var _formattedLastUpdate: String { |
||||||
|
let encodedDate = self.date.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" |
||||||
|
return encodedDate.replacingOccurrences(of: "+", with: "%2B") |
||||||
|
} |
||||||
|
|
||||||
|
static func relationships() -> [Relationship] { return [] } |
||||||
|
public static func parentRelationships() -> [Relationship] { return [] } |
||||||
|
public static func childrenRelationships() -> [Relationship] { return [] } |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
// |
||||||
|
// WaitingOperation.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 01/04/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
enum StorageMethod: String, Codable { |
||||||
|
case add |
||||||
|
case update |
||||||
|
case delete |
||||||
|
case deleteUnusedShared |
||||||
|
} |
||||||
|
|
||||||
|
class PendingOperation<T : Storable>: Codable, Equatable { |
||||||
|
|
||||||
|
var id: String = Store.randomId() |
||||||
|
var method: StorageMethod |
||||||
|
var data: T |
||||||
|
var actionOption: ActionOption |
||||||
|
|
||||||
|
init(method: StorageMethod, data: T, actionOption: ActionOption) { |
||||||
|
self.method = method |
||||||
|
self.data = data |
||||||
|
self.actionOption = actionOption |
||||||
|
} |
||||||
|
|
||||||
|
static func == (lhs: PendingOperation, rhs: PendingOperation) -> Bool { |
||||||
|
return lhs.id == rhs.id |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
// |
||||||
|
// SyncData.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 02/05/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
enum SyncDataError: Error { |
||||||
|
case invalidFormat |
||||||
|
} |
||||||
|
|
||||||
|
struct SyncedStorableArray { |
||||||
|
var type: any SyncedStorable.Type |
||||||
|
var items: [any SyncedStorable] |
||||||
|
} |
||||||
|
|
||||||
|
struct ObjectIdentifierArray { |
||||||
|
var type: any SyncedStorable.Type |
||||||
|
var items: [ObjectIdentifier] |
||||||
|
} |
||||||
|
|
||||||
|
class SyncData { |
||||||
|
|
||||||
|
var updates: [SyncedStorableArray] = [] |
||||||
|
var deletions: [ObjectIdentifierArray] = [] |
||||||
|
var shared: [SyncedStorableArray] = [] |
||||||
|
var grants: [SyncedStorableArray] = [] |
||||||
|
var revocations: [ObjectIdentifierArray] = [] |
||||||
|
var revocationParents: [[ObjectIdentifierArray]] = [] |
||||||
|
// var relationshipSets: [SyncedStorableArray] = [] |
||||||
|
// var relationshipRemovals: [ObjectIdentifierArray] = [] |
||||||
|
var sharedRelationshipSets: [SyncedStorableArray] = [] |
||||||
|
var sharedRelationshipRemovals: [ObjectIdentifierArray] = [] |
||||||
|
var date: String? |
||||||
|
|
||||||
|
init(data: Data, storeCenter: StoreCenter) throws { |
||||||
|
guard let json = try JSONSerialization.jsonObject(with: data, options: []) |
||||||
|
as? [String : Any] |
||||||
|
else { |
||||||
|
throw SyncDataError.invalidFormat |
||||||
|
} |
||||||
|
|
||||||
|
if let updates = json["updates"] as? [String: Any] { |
||||||
|
self.updates = try storeCenter.decodeDictionary(updates) |
||||||
|
} |
||||||
|
if let deletions = json["deletions"] as? [String: Any] { |
||||||
|
self.deletions = try storeCenter.decodeObjectIdentifierDictionary(deletions) |
||||||
|
} |
||||||
|
if let shared = json["shared"] as? [String: Any] { |
||||||
|
self.shared = try storeCenter.decodeDictionary(shared) |
||||||
|
} |
||||||
|
if let grants = json["grants"] as? [String: Any] { |
||||||
|
self.grants = try storeCenter.decodeDictionary(grants) |
||||||
|
} |
||||||
|
if let revocations = json["revocations"] as? [String: Any] { |
||||||
|
self.revocations = try storeCenter.decodeObjectIdentifierDictionary(revocations) |
||||||
|
} |
||||||
|
if let revocationParents = json["revocated_relations"] as? [[String: Any]] { |
||||||
|
for level in revocationParents { |
||||||
|
let decodedLevel = try storeCenter.decodeObjectIdentifierDictionary(level) |
||||||
|
self.revocationParents.append(decodedLevel) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// if let relationshipSets = json["relationship_sets"] as? [String: Any] { |
||||||
|
// self.relationshipSets = try storeCenter.decodeDictionary(relationshipSets) |
||||||
|
// } |
||||||
|
// if let relationshipRemovals = json["relationship_removals"] as? [String: Any] { |
||||||
|
// self.relationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(relationshipRemovals) |
||||||
|
// } |
||||||
|
if let sharedRelationshipSets = json["shared_relationship_sets"] as? [String: Any] { |
||||||
|
self.sharedRelationshipSets = try storeCenter.decodeDictionary(sharedRelationshipSets) |
||||||
|
} |
||||||
|
if let sharedRelationshipRemovals = json["shared_relationship_removals"] as? [String: Any] { |
||||||
|
self.sharedRelationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(sharedRelationshipRemovals) |
||||||
|
} |
||||||
|
|
||||||
|
self.date = json["date"] as? String |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
// |
||||||
|
// NetworkMonitor.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 25/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Network |
||||||
|
import Foundation |
||||||
|
|
||||||
|
public class NetworkMonitor { |
||||||
|
|
||||||
|
public static let shared = NetworkMonitor() |
||||||
|
|
||||||
|
private var _monitor: NWPathMonitor |
||||||
|
private var _queue = DispatchQueue(label: "lestorage.queue.network_monitor") |
||||||
|
|
||||||
|
public var isConnected: Bool { |
||||||
|
get { |
||||||
|
return status == .satisfied |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private(set) var status: NWPath.Status = .requiresConnection |
||||||
|
|
||||||
|
// Closure to be called when connection is established |
||||||
|
var onConnectionEstablished: (() -> Void)? |
||||||
|
|
||||||
|
private init() { |
||||||
|
_monitor = NWPathMonitor() |
||||||
|
self._startMonitoring() |
||||||
|
} |
||||||
|
|
||||||
|
private func _startMonitoring() { |
||||||
|
_monitor.pathUpdateHandler = { [weak self] path in |
||||||
|
guard let self = self else { return } |
||||||
|
|
||||||
|
// Update status |
||||||
|
self.status = path.status |
||||||
|
|
||||||
|
// Print status for debugging |
||||||
|
Logger.log("Network Status: \(path.status)") |
||||||
|
|
||||||
|
// Handle connection established |
||||||
|
if path.status == .satisfied { |
||||||
|
DispatchQueue.main.async { |
||||||
|
self.onConnectionEstablished?() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
self._monitor.start(queue: self._queue) |
||||||
|
} |
||||||
|
|
||||||
|
func stopMonitoring() { |
||||||
|
self._monitor.cancel() |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
// |
||||||
|
// Notification+Name.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 15/01/2025. |
||||||
|
// |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 static let LeStorageDidSynchronize: Notification.Name = Notification.Name.init( |
||||||
|
"notification.leStorageDidSynchronize") |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
// |
||||||
|
// PendingOperationManager.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 01/04/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
class PendingOperationManager<T: Storable> { |
||||||
|
|
||||||
|
fileprivate(set) var items: [PendingOperation<T>] = [] |
||||||
|
|
||||||
|
fileprivate var _fileName: String |
||||||
|
|
||||||
|
fileprivate var _inMemory: Bool = false |
||||||
|
|
||||||
|
init(store: Store, inMemory: Bool) { |
||||||
|
self._fileName = "\(store.storeCenter.directoryName)/pending_\(T.resourceName()).json" |
||||||
|
|
||||||
|
self._inMemory = inMemory |
||||||
|
if !inMemory { |
||||||
|
do { |
||||||
|
let url = try store.fileURL(fileName: self._fileName) |
||||||
|
if FileManager.default.fileExists(atPath: url.path()) { |
||||||
|
let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName) |
||||||
|
if let decoded: [PendingOperation<T>] = try jsonString.decode() { |
||||||
|
self.items = decoded |
||||||
|
} |
||||||
|
} |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var typeName: String { return String(describing: T.self) } |
||||||
|
|
||||||
|
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) { |
||||||
|
Logger.log("addPendingOperation: \(method), \(instance)") |
||||||
|
|
||||||
|
let operation = PendingOperation<T>(method: method, data: instance, actionOption: actionOption) |
||||||
|
self.items.append(operation) |
||||||
|
|
||||||
|
self._writeIfNecessary() |
||||||
|
} |
||||||
|
|
||||||
|
func reset() { |
||||||
|
self.items.removeAll() |
||||||
|
self._writeIfNecessary() |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _writeIfNecessary() { |
||||||
|
guard !self._inMemory else { return } |
||||||
|
do { |
||||||
|
let jsonString: String = try self.items.jsonString() |
||||||
|
Task(priority: .background) { |
||||||
|
let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: self._fileName) |
||||||
|
} |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
// |
||||||
|
// Relationship.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 27/11/2024. |
||||||
|
// |
||||||
|
|
||||||
|
public enum StoreLookup { |
||||||
|
case same |
||||||
|
case main |
||||||
|
case child |
||||||
|
} |
||||||
|
|
||||||
|
public struct Relationship { |
||||||
|
|
||||||
|
public init(type: any Storable.Type, keyPath: AnyKeyPath, storeLookup: StoreLookup) { |
||||||
|
self.type = type |
||||||
|
self.keyPath = keyPath |
||||||
|
self.storeLookup = storeLookup |
||||||
|
} |
||||||
|
|
||||||
|
/// The type of the relationship |
||||||
|
var type: any Storable.Type |
||||||
|
|
||||||
|
/// the keyPath to access the relationship |
||||||
|
var keyPath: AnyKeyPath |
||||||
|
|
||||||
|
/// Indicates whether the linked object is on the main Store |
||||||
|
var storeLookup: StoreLookup |
||||||
|
|
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,64 @@ |
|||||||
|
// |
||||||
|
// StoreLibrary.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 02/06/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
class StoreLibrary { |
||||||
|
|
||||||
|
private let storeCenter: StoreCenter |
||||||
|
|
||||||
|
/// A dictionary of Stores associated to their id |
||||||
|
fileprivate var _stores: [String: Store] = [:] |
||||||
|
|
||||||
|
init(storeCenter: StoreCenter) { |
||||||
|
self.storeCenter = storeCenter |
||||||
|
} |
||||||
|
|
||||||
|
subscript(identifier: String) -> Store? { |
||||||
|
get { |
||||||
|
return self._stores[identifier] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Registers a store into the list of stores |
||||||
|
/// - Parameters: |
||||||
|
/// - store: A store to save |
||||||
|
fileprivate func _registerStore(store: Store) { |
||||||
|
guard let identifier = store.identifier else { |
||||||
|
fatalError("The store has no identifier") |
||||||
|
} |
||||||
|
if self._stores[identifier] != nil { |
||||||
|
fatalError("A store with this identifier has already been registered: \(identifier)") |
||||||
|
} |
||||||
|
self._stores[identifier] = store |
||||||
|
} |
||||||
|
|
||||||
|
/// Returns a store using its identifier, and registers it if it does not exists |
||||||
|
/// - Parameters: |
||||||
|
/// - identifier: The store identifer |
||||||
|
/// - parameter: The parameter name used to filter data on the server |
||||||
|
func requestStore(identifier: String) -> Store { |
||||||
|
if let store = self._stores[identifier] { |
||||||
|
return store |
||||||
|
} else { |
||||||
|
let store = Store(storeCenter: self.storeCenter, identifier: identifier) |
||||||
|
self._registerStore(store: store) |
||||||
|
return store |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public func destroyStore(identifier: String) { |
||||||
|
let directory = "\(self.storeCenter.directoryName)/\(identifier)" |
||||||
|
FileManager.default.deleteDirectoryInDocuments(directoryName: directory) |
||||||
|
self._stores[identifier]?.reset() |
||||||
|
self._stores.removeValue(forKey: identifier) |
||||||
|
} |
||||||
|
|
||||||
|
func reset() { |
||||||
|
self._stores.removeAll() |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,478 @@ |
|||||||
|
// |
||||||
|
// SyncedCollection.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 11/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
protocol SomeSyncedCollection: SomeCollection { |
||||||
|
func loadDataFromServerIfAllowed(clear: Bool) async throws |
||||||
|
func loadCollectionsFromServerIfNoFile() async throws |
||||||
|
} |
||||||
|
|
||||||
|
public class SyncedCollection<T : SyncedStorable>: SomeSyncedCollection, CollectionDelegate { |
||||||
|
|
||||||
|
public typealias Item = T |
||||||
|
|
||||||
|
let store: Store |
||||||
|
let collection: StoredCollection<T> |
||||||
|
|
||||||
|
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) { |
||||||
|
|
||||||
|
self.store = store |
||||||
|
self.collection = StoredCollection<T>(store: store, indexed: indexed, inMemory: inMemory, limit: limit, noLoad: noLoad) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
init(store: Store, inMemory: Bool) async { |
||||||
|
self.store = store |
||||||
|
self.collection = await StoredCollection(store: store, inMemory: inMemory) |
||||||
|
} |
||||||
|
|
||||||
|
var storeCenter: StoreCenter { return self.store.storeCenter } |
||||||
|
|
||||||
|
public var storeId: String? { |
||||||
|
return self.store.identifier |
||||||
|
} |
||||||
|
|
||||||
|
/// Returns a dummy SyncedCollection instance |
||||||
|
public static func placeholder() -> SyncedCollection<T> { |
||||||
|
return SyncedCollection<T>(store: Store(storeCenter: StoreCenter.main)) |
||||||
|
} |
||||||
|
|
||||||
|
/// 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() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Retrieves the data from the server and loads it into the items array |
||||||
|
public func loadDataFromServerIfAllowed(clear: Bool = false) async throws { |
||||||
|
do { |
||||||
|
try await self.storeCenter.sendGetRequest(T.self, storeId: self.storeId, clear: clear) |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func loadOnceAsync() async throws { |
||||||
|
let items: [T] = try await self.storeCenter.service().get() |
||||||
|
await self.loadItems(items, clear: true) |
||||||
|
} |
||||||
|
|
||||||
|
/// Updates a local item from a server instance. This method is typically used when the server makes update |
||||||
|
/// to an object when it's inserted. The SyncedCollection possibly needs to update its own copy with new values. |
||||||
|
/// - serverInstance: the instance of the object on the server |
||||||
|
func updateFromServerInstance(_ serverInstance: T) { |
||||||
|
|
||||||
|
guard T.copyServerResponse else { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
Task { |
||||||
|
await self.collection.updateLocalInstance(serverInstance) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
@MainActor |
||||||
|
func loadItems(_ items: [T], clear: Bool = false) { |
||||||
|
self.collection.loadAndWrite(items, clear: clear) |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Basic operations with sync |
||||||
|
|
||||||
|
/// Adds or update an instance synchronously, dispatching network operations to background tasks |
||||||
|
public func addOrUpdate(instance: T) { |
||||||
|
let result = _addOrUpdateCore(instance: instance) |
||||||
|
if result.method == .insert { |
||||||
|
Task { await self._sendInsertion(instance) } |
||||||
|
} else { |
||||||
|
Task { await self._sendUpdate(instance) } |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Private helper function that contains the shared logic |
||||||
|
private func _addOrUpdateCore(instance: T) -> ActionResult<T> { |
||||||
|
instance.lastUpdate = Date() |
||||||
|
|
||||||
|
let result = self.collection.addOrUpdate(instance: instance) |
||||||
|
if result.method == .update { |
||||||
|
if instance.sharing != nil { |
||||||
|
self._cleanUpSharedDependencies() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return result |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _addOrUpdateCore(contentOfs sequence: any Sequence<T>) -> OperationBatch<T> { |
||||||
|
|
||||||
|
let date = Date() |
||||||
|
let batch = OperationBatch<T>() |
||||||
|
|
||||||
|
for instance in sequence { |
||||||
|
|
||||||
|
instance.lastUpdate = date |
||||||
|
let result = self.collection.addOrUpdate(instance: instance) |
||||||
|
|
||||||
|
if result.method == .insert { |
||||||
|
batch.addInsert(instance) |
||||||
|
} else { |
||||||
|
batch.addUpdate(instance) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
self._cleanUpSharedDependencies() |
||||||
|
|
||||||
|
return batch |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/// Adds or update a sequence and writes |
||||||
|
public func addOrUpdate(contentOfs sequence: any Sequence<T>) { |
||||||
|
let batch = self._addOrUpdateCore(contentOfs: sequence) |
||||||
|
Task { await self._sendOperationBatch(batch) } |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes an instance and writes |
||||||
|
public func delete(instance: T) { |
||||||
|
|
||||||
|
self.collection.delete(instance: instance, actionOption: .syncedCascade) |
||||||
|
self.storeCenter.createDeleteLog(instance) |
||||||
|
Task { await self._sendDeletion(instance) } |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
||||||
|
public func delete(contentOfs sequence: any RandomAccessCollection<T>) { |
||||||
|
self.delete(contentOfs: sequence, actionOption: .syncedCascade) |
||||||
|
} |
||||||
|
|
||||||
|
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) { |
||||||
|
guard sequence.isNotEmpty else { return } |
||||||
|
let batch = self._deleteCore(contentOfs: sequence, actionOption: actionOption) |
||||||
|
if actionOption.synchronize { |
||||||
|
Task { await self._sendOperationBatch(batch) } |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
||||||
|
fileprivate func _deleteCore(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) -> OperationBatch<T> { |
||||||
|
|
||||||
|
var deleted: [T] = [] |
||||||
|
self.collection.delete(contentOfs: sequence, actionOption: actionOption) { result in |
||||||
|
self.storeCenter.createDeleteLog(result.instance) |
||||||
|
if !result.pending { |
||||||
|
deleted.append(result.instance) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let batch = OperationBatch<T>() |
||||||
|
batch.deletes = deleted |
||||||
|
return batch |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _cleanUpSharedDependencies() { |
||||||
|
for relationship in T.relationships() { |
||||||
|
if let syncedType = relationship.type as? (any SyncedStorable.Type) { |
||||||
|
do { |
||||||
|
try self._deleteUnusedSharedInstances(relationship: relationship, type: syncedType, originStoreId: self.storeId) |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _deleteUnusedSharedInstances<S: SyncedStorable>(relationship: Relationship, type: S.Type, originStoreId: String?) throws { |
||||||
|
|
||||||
|
let store: Store |
||||||
|
switch relationship.storeLookup { |
||||||
|
case .main: store = self.store.storeCenter.mainStore |
||||||
|
case .same: store = self.store |
||||||
|
case .child: |
||||||
|
throw StoreError.invalidStoreLookup(from: type, to: relationship.type) |
||||||
|
} |
||||||
|
|
||||||
|
let collection: SyncedCollection<S> = try store.syncedCollection() |
||||||
|
collection._deleteUnusedGrantedInstances(originStoreId: originStoreId) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _deleteUnusedGrantedInstances(originStoreId: String?) { |
||||||
|
|
||||||
|
let sharedItems = self.collection.items.filter { $0.sharing == .granted } |
||||||
|
for sharedItem in sharedItems { |
||||||
|
self.store.deleteUnusedGrantedIfNecessary(sharedItem, originStoreId: originStoreId |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public func deleteAllItemsAndDependencies(actionOption: ActionOption) { |
||||||
|
if actionOption.synchronize { |
||||||
|
self.delete(contentOfs: self.items, actionOption: actionOption) |
||||||
|
} else { |
||||||
|
self.collection.deleteAllItemsAndDependencies(actionOption: actionOption) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) { |
||||||
|
let items = self.items.filter(isIncluded) |
||||||
|
if actionOption.synchronize { |
||||||
|
self.delete(contentOfs: items, actionOption: actionOption) |
||||||
|
} else { |
||||||
|
self.collection.delete(contentOfs: items) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Asynchronous operations |
||||||
|
|
||||||
|
/// Adds or update an instance asynchronously and waits for network operations |
||||||
|
public func addOrUpdateAsync(instance: T) async throws { |
||||||
|
let result = _addOrUpdateCore(instance: instance) |
||||||
|
if result.method == .insert { |
||||||
|
try await self._executeBatchOnce(OperationBatch(insert: instance)) |
||||||
|
} else { |
||||||
|
try await self._executeBatchOnce(OperationBatch(update: instance)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public func addOrUpdateAsync(contentOfs sequence: any Sequence<T>) async throws { |
||||||
|
let batch = self._addOrUpdateCore(contentOfs: sequence) |
||||||
|
try await self._executeBatchOnce(batch) |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
||||||
|
public func deleteAsync(contentOfs sequence: any RandomAccessCollection<T>) async throws { |
||||||
|
guard sequence.isNotEmpty else { return } |
||||||
|
let batch = self._deleteCore(contentOfs: sequence, actionOption: .syncedCascade) |
||||||
|
try await self._executeBatchOnce(batch) |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes an instance and writes |
||||||
|
public func deleteAsync(instance: T) async throws { |
||||||
|
self.collection.delete(instance: instance, actionOption: .syncedCascade) |
||||||
|
self.storeCenter.createDeleteLog(instance) |
||||||
|
try await self._executeBatchOnce(OperationBatch(delete: instance)) |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Basic operations without sync |
||||||
|
|
||||||
|
/// Adds or update an instance without synchronizing it |
||||||
|
func addOrUpdateNoSync(_ instance: T) { |
||||||
|
self.collection.addOrUpdate(instance: instance) |
||||||
|
// self.addOrUpdateItem(instance: instance) |
||||||
|
} |
||||||
|
|
||||||
|
/// Adds or update a sequence of elements without synchronizing it |
||||||
|
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) { |
||||||
|
self.collection.addOrUpdate(contentOfs: sequence) |
||||||
|
} |
||||||
|
|
||||||
|
public func deleteNoSync(contentOfs sequence: any RandomAccessCollection<T>) { |
||||||
|
self.collection.delete(contentOfs: sequence) |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes the instance in the collection without synchronization |
||||||
|
public func deleteNoSync(instance: T, cascading: Bool = false) { |
||||||
|
self.collection.delete(instance: instance, actionOption: .cascade) |
||||||
|
} |
||||||
|
|
||||||
|
func deleteUnusedGranted(instance: T) { |
||||||
|
guard instance.sharing != nil else { return } |
||||||
|
self.deleteByStringId(instance.stringId) |
||||||
|
instance.deleteUnusedSharedDependencies(store: self.store) |
||||||
|
} |
||||||
|
|
||||||
|
func deleteByStringId(_ id: String, actionOption: ActionOption = .standard) { |
||||||
|
self.collection.deleteByStringId(id, actionOption: actionOption) |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Collection Delegate |
||||||
|
|
||||||
|
func loadingForMemoryCollection() async { |
||||||
|
do { |
||||||
|
try await self.loadDataFromServerIfAllowed() |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func itemMerged(_ pendingOperation: PendingOperation<T>) { |
||||||
|
|
||||||
|
let batch = OperationBatch<T>() |
||||||
|
switch pendingOperation.method { |
||||||
|
case .add: |
||||||
|
batch.inserts.append(pendingOperation.data) |
||||||
|
case .update: |
||||||
|
batch.updates.append(pendingOperation.data) |
||||||
|
case .delete: |
||||||
|
batch.deletes.append(pendingOperation.data) |
||||||
|
case .deleteUnusedShared: |
||||||
|
break |
||||||
|
} |
||||||
|
|
||||||
|
Task { await self._sendOperationBatch(batch) } |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Send requests |
||||||
|
|
||||||
|
fileprivate func _sendInsertion(_ instance: T) async { |
||||||
|
await self._sendOperationBatch(OperationBatch(insert: instance)) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _sendUpdate(_ instance: T) async { |
||||||
|
await self._sendOperationBatch(OperationBatch(update: instance)) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _sendDeletion(_ instance: T) async { |
||||||
|
await self._sendOperationBatch(OperationBatch(delete: instance)) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _sendOperationBatch(_ batch: OperationBatch<T>) async { |
||||||
|
do { |
||||||
|
try await self.storeCenter.sendOperationBatch(batch) |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _executeBatchOnce(_ batch: OperationBatch<T>) async throws { |
||||||
|
try await self.storeCenter.singleBatchExecution(batch) |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: Single calls |
||||||
|
|
||||||
|
public func addsIfPostSucceeds(_ instance: T) async throws { |
||||||
|
if let result = try await self.storeCenter.service().post(instance) { |
||||||
|
self.addOrUpdateNoSync(result) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public func updateIfPutSucceeds(_ instance: T) async throws { |
||||||
|
if let result = try await self.storeCenter.service().put(instance) { |
||||||
|
self.addOrUpdateNoSync(result) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Synchronization |
||||||
|
|
||||||
|
/// Adds or update an instance if it is newer than the local instance |
||||||
|
func addOrUpdateIfNewer(_ instance: T, shared: SharingStatus?) { |
||||||
|
|
||||||
|
if let index = self.collection.items.firstIndex(where: { $0.id == instance.id }) { |
||||||
|
let localInstance = self.collection.items[index] |
||||||
|
if instance.lastUpdate > localInstance.lastUpdate { |
||||||
|
self.collection.update(instance, index: index, actionOption: .standard) |
||||||
|
} else { |
||||||
|
// print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)") |
||||||
|
} |
||||||
|
} else { // insert |
||||||
|
instance.sharing = shared |
||||||
|
self.collection.add(instance: instance, actionOption: .standard) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Others |
||||||
|
|
||||||
|
/// Sends a POST request for the instance, and changes the collection to perform a write |
||||||
|
public func writeChangeAndInsertOnServer(instance: T) { |
||||||
|
|
||||||
|
self.collection.addOrUpdate(instance: instance) |
||||||
|
Task { |
||||||
|
await self._sendInsertion(instance) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - SomeCollection |
||||||
|
|
||||||
|
public var hasLoaded: Bool { return self.collection.hasLoaded} |
||||||
|
|
||||||
|
public var inMemory: Bool { return self.collection.inMemory } |
||||||
|
|
||||||
|
public var type: any Storable.Type { return T.self } |
||||||
|
|
||||||
|
public func hasParentReferences<S>(type: S.Type, id: String) -> Bool where S : Storable { |
||||||
|
return self.collection.hasParentReferences(type: type, id: id) |
||||||
|
} |
||||||
|
|
||||||
|
public func reset() { |
||||||
|
self.collection.reset() |
||||||
|
} |
||||||
|
|
||||||
|
public func findById(_ id: T.ID) -> T? { |
||||||
|
return self.collection.findById(id) |
||||||
|
} |
||||||
|
|
||||||
|
public var items: [T] { |
||||||
|
return self.collection.items |
||||||
|
} |
||||||
|
|
||||||
|
public func requestWriteIfNecessary() { |
||||||
|
self.collection.requestWriteIfNecessary() |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Cached queries |
||||||
|
|
||||||
|
public func cached<Result>( |
||||||
|
key: AnyHashable, |
||||||
|
compute: ([T]) -> Result |
||||||
|
) -> Result { |
||||||
|
return self.collection.cached(key: key, compute: compute) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
class OperationBatch<T> { |
||||||
|
var inserts: [T] = [] |
||||||
|
var updates: [T] = [] |
||||||
|
var deletes: [T] = [] |
||||||
|
|
||||||
|
init() { |
||||||
|
|
||||||
|
} |
||||||
|
init(insert: T) { |
||||||
|
self.inserts = [insert] |
||||||
|
} |
||||||
|
init(update: T) { |
||||||
|
self.updates = [update] |
||||||
|
} |
||||||
|
init(delete: T) { |
||||||
|
self.deletes = [delete] |
||||||
|
} |
||||||
|
|
||||||
|
func addInsert(_ instance: T) { |
||||||
|
self.inserts.append(instance) |
||||||
|
} |
||||||
|
func addUpdate(_ instance: T) { |
||||||
|
self.updates.append(instance) |
||||||
|
} |
||||||
|
func addDelete(_ instance: T) { |
||||||
|
self.deletes.append(instance) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension SyncedCollection: RandomAccessCollection { |
||||||
|
|
||||||
|
public var startIndex: Int { return self.collection.items.startIndex } |
||||||
|
|
||||||
|
public var endIndex: Int { return self.collection.items.endIndex } |
||||||
|
|
||||||
|
public func index(after i: Int) -> Int { |
||||||
|
return self.collection.items.index(after: i) |
||||||
|
} |
||||||
|
|
||||||
|
public subscript(index: Int) -> T { |
||||||
|
get { |
||||||
|
return self.collection.items[index] |
||||||
|
} |
||||||
|
set(newValue) { |
||||||
|
self.collection.update(newValue, index: index, actionOption: .standard) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
// |
||||||
|
// SyncedStorable.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 11/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
public enum SharingStatus: Int, Codable { |
||||||
|
case shared = 1 |
||||||
|
case granted |
||||||
|
} |
||||||
|
|
||||||
|
public protocol SyncedStorable: Storable { |
||||||
|
|
||||||
|
var lastUpdate: Date { get set } |
||||||
|
var sharing: SharingStatus? { get set } |
||||||
|
|
||||||
|
init() |
||||||
|
|
||||||
|
/// Returns HTTP methods that do not need to pass the token to the request |
||||||
|
static func tokenExemptedMethods() -> [HTTPMethod] |
||||||
|
|
||||||
|
/// Returns whether we should copy the server response into the local instance |
||||||
|
static var copyServerResponse: Bool { get } |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
protocol URLParameterConvertible { |
||||||
|
func queryParameters(storeCenter: StoreCenter) -> [String : String] |
||||||
|
} |
||||||
|
|
||||||
|
public protocol SideStorable { |
||||||
|
var storeId: String? { get set } |
||||||
|
} |
||||||
|
|
||||||
|
extension Storable { |
||||||
|
|
||||||
|
func getStoreId() -> String? { |
||||||
|
if let alt = self as? SideStorable { |
||||||
|
return alt.storeId |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
public extension SyncedStorable { |
||||||
|
|
||||||
|
func copy() -> Self { |
||||||
|
let copy = Self() |
||||||
|
copy.copy(from: self) |
||||||
|
return copy |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,50 @@ |
|||||||
|
// |
||||||
|
// ClassLoader.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 22/11/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
class ClassLoader { |
||||||
|
static var classCache: [String : AnyClass] = [:] |
||||||
|
|
||||||
|
static func getClass(_ className: String, classProject: String? = nil) -> AnyClass? { |
||||||
|
if let cachedClass = classCache[className] { |
||||||
|
return cachedClass |
||||||
|
} |
||||||
|
|
||||||
|
if let bundleName = Bundle.main.infoDictionary?["CFBundleName"] as? String { |
||||||
|
let sanitizedBundleName = bundleName.replacingOccurrences(of: " ", with: "_") |
||||||
|
let fullName = "\(sanitizedBundleName).\(className)" |
||||||
|
if let projectClass = _getClass(fullName) { |
||||||
|
return projectClass |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if let classProject { |
||||||
|
let sanitizedBundleName = classProject.replacingOccurrences(of: " ", with: "_") |
||||||
|
let fullName = "\(sanitizedBundleName).\(className)" |
||||||
|
if let projectClass = _getClass(fullName) { |
||||||
|
return projectClass |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let leStorageClassName = "LeStorage.\(className)" |
||||||
|
if let projectClass = _getClass(leStorageClassName) { |
||||||
|
return projectClass |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
static func _getClass(_ className: String) -> AnyClass? { |
||||||
|
if let loadedClass = NSClassFromString(className) { |
||||||
|
classCache[className] = loadedClass |
||||||
|
return loadedClass |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,33 @@ |
|||||||
|
// |
||||||
|
// Date+Extensions.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 09/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension Date { |
||||||
|
|
||||||
|
static var iso8601Formatter: ISO8601DateFormatter = { |
||||||
|
let iso8601Formatter = ISO8601DateFormatter() |
||||||
|
iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") |
||||||
|
iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone] |
||||||
|
return iso8601Formatter |
||||||
|
}() |
||||||
|
|
||||||
|
public static var iso8601FractionalFormatter: ISO8601DateFormatter = { |
||||||
|
let iso8601Formatter = ISO8601DateFormatter() |
||||||
|
iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") |
||||||
|
iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone, .withFractionalSeconds] |
||||||
|
return iso8601Formatter |
||||||
|
}() |
||||||
|
|
||||||
|
public static var microSecondFormatter: DateFormatter = { |
||||||
|
let formatter = DateFormatter() |
||||||
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" // puts 000 for the last decimals |
||||||
|
formatter.timeZone = TimeZone(abbreviation: "UTC") |
||||||
|
return formatter |
||||||
|
}() |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
// |
||||||
|
// Dictionary+Extensions.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 03/12/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension Dictionary where Key == String, Value == String { |
||||||
|
|
||||||
|
func toQueryString() -> String { |
||||||
|
guard !self.isEmpty else { |
||||||
|
return "" |
||||||
|
} |
||||||
|
|
||||||
|
let pairs = self.map { key, value in |
||||||
|
let escapedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key |
||||||
|
let escapedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value |
||||||
|
return "\(escapedKey)=\(escapedValue)" |
||||||
|
} |
||||||
|
|
||||||
|
return "?" + pairs.joined(separator: "&") |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
// |
||||||
|
// Formatter.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 30/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
class Formatter { |
||||||
|
|
||||||
|
static let number: NumberFormatter = NumberFormatter() |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
// |
||||||
|
// MockKeychainStore.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 17/04/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
class TokenStore: MicroStorable { |
||||||
|
required init() { |
||||||
|
|
||||||
|
} |
||||||
|
var token: String? |
||||||
|
} |
||||||
|
|
||||||
|
class MockKeychainStore: MicroStorage<TokenStore>, KeychainService { |
||||||
|
|
||||||
|
let key = "store" |
||||||
|
|
||||||
|
func add(username: String, value: String) throws { |
||||||
|
try self.add(value: value) |
||||||
|
} |
||||||
|
|
||||||
|
func add(value: String) throws { |
||||||
|
self.update { tokenStore in |
||||||
|
tokenStore.token = value |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func getValue() throws -> String { |
||||||
|
if let value = self.item.token { |
||||||
|
return value |
||||||
|
} |
||||||
|
throw KeychainError.keychainItemNotFound(serverId: "mock") |
||||||
|
} |
||||||
|
|
||||||
|
func deleteValue() throws { |
||||||
|
self.update { tokenStore in |
||||||
|
tokenStore.token = nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
// |
||||||
|
// String+Extensions.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 19/05/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
public extension String { |
||||||
|
|
||||||
|
static func random(length: Int = 10) -> String { |
||||||
|
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" |
||||||
|
return String((0..<length).map{ _ in letters.randomElement()! }) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
// |
||||||
|
// UIDevice+Extensions.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 20/03/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
import UIKit |
||||||
|
|
||||||
|
extension UIDevice { |
||||||
|
|
||||||
|
func deviceModel() -> String { |
||||||
|
var systemInfo = utsname() |
||||||
|
uname(&systemInfo) |
||||||
|
let modelCode = withUnsafePointer(to: &systemInfo.machine) { |
||||||
|
$0.withMemoryRebound(to: CChar.self, capacity: 1) { |
||||||
|
ptr in String(validatingUTF8: ptr) |
||||||
|
} |
||||||
|
} |
||||||
|
return modelCode ?? "unknown" |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
// |
||||||
|
// URLManager.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 18/11/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
struct URLManager { |
||||||
|
|
||||||
|
var secureScheme: Bool |
||||||
|
var domain: String |
||||||
|
private let apiPath: String = "roads" |
||||||
|
|
||||||
|
var api: String { |
||||||
|
return "\(self.httpScheme)\(self.domain)/\(self.apiPath)/" |
||||||
|
} |
||||||
|
|
||||||
|
func websocket(userId: String) -> String { |
||||||
|
return "\(self.wsScheme)\(self.domain)/ws/user/\(userId)/" |
||||||
|
} |
||||||
|
|
||||||
|
var httpScheme: String { |
||||||
|
if self.secureScheme { |
||||||
|
return "https://" |
||||||
|
} else { |
||||||
|
return "http://" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var wsScheme: String { |
||||||
|
if self.secureScheme { |
||||||
|
return "wss://" |
||||||
|
} else { |
||||||
|
return "ws://" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,151 @@ |
|||||||
|
// |
||||||
|
// WebSocketManager.swift |
||||||
|
// WebSocketTest |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 30/08/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
import SwiftUI |
||||||
|
import Combine |
||||||
|
|
||||||
|
class WebSocketManager: ObservableObject { |
||||||
|
|
||||||
|
fileprivate(set) var storeCenter: StoreCenter |
||||||
|
|
||||||
|
fileprivate var _webSocketTask: URLSessionWebSocketTask? |
||||||
|
fileprivate var _timer: Timer? |
||||||
|
fileprivate var _url: String |
||||||
|
|
||||||
|
fileprivate var _reconnectAttempts = 0 |
||||||
|
fileprivate var _failure = false |
||||||
|
fileprivate var _error: Error? = nil |
||||||
|
fileprivate var _pingOk = false |
||||||
|
|
||||||
|
init(storeCenter: StoreCenter, urlString: String) { |
||||||
|
self.storeCenter = storeCenter |
||||||
|
self._url = urlString |
||||||
|
_setupWebSocket() |
||||||
|
} |
||||||
|
|
||||||
|
deinit { |
||||||
|
disconnect() |
||||||
|
} |
||||||
|
|
||||||
|
private func _setupWebSocket() { |
||||||
|
|
||||||
|
// guard let url = URL(string: "ws://127.0.0.1:8000/ws/user/test/") else { |
||||||
|
guard let url = URL(string: self._url) else { |
||||||
|
Logger.w("Invalid URL: \(self._url)") |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
Logger.log(">>> configure websockets with: \(url)") |
||||||
|
|
||||||
|
let session = URLSession(configuration: .default) |
||||||
|
_webSocketTask = session.webSocketTask(with: url) |
||||||
|
_webSocketTask?.resume() |
||||||
|
|
||||||
|
self._receiveMessage() |
||||||
|
|
||||||
|
// Setup a ping timer to keep the connection alive |
||||||
|
self._timer?.invalidate() |
||||||
|
_timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in |
||||||
|
self._ping() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func _receiveMessage() { |
||||||
|
_webSocketTask?.receive { result in |
||||||
|
switch result { |
||||||
|
case .failure(let error): |
||||||
|
self._failure = true |
||||||
|
self._error = error |
||||||
|
print("Error in receiving message: \(error)") |
||||||
|
self._handleWebSocketError(error) |
||||||
|
case .success(let message): |
||||||
|
self._failure = false |
||||||
|
self._error = nil |
||||||
|
self._reconnectAttempts = 0 |
||||||
|
switch message { |
||||||
|
case .string(let deviceId): |
||||||
|
// print("device id = \(StoreCenter.main.deviceId()), origin id: \(deviceId)") |
||||||
|
guard self.storeCenter.deviceId() != deviceId else { |
||||||
|
break |
||||||
|
} |
||||||
|
|
||||||
|
Task { |
||||||
|
await self.storeCenter.synchronizeLastUpdates() |
||||||
|
} |
||||||
|
|
||||||
|
case .data(let data): |
||||||
|
print("Received binary message: \(data)") |
||||||
|
break |
||||||
|
@unknown default: |
||||||
|
print("received other = \(message)") |
||||||
|
break |
||||||
|
} |
||||||
|
|
||||||
|
self._receiveMessage() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func _handleWebSocketError(_ error: Error) { |
||||||
|
// print("WebSocket error: \(error)") |
||||||
|
|
||||||
|
// up to 10 seconds of reconnection |
||||||
|
let delay = min(Double(self._reconnectAttempts), 10.0) |
||||||
|
self._reconnectAttempts += 1 |
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in |
||||||
|
guard let self = self else { return } |
||||||
|
Logger.log("Attempting to reconnect... (Attempt #\(self._reconnectAttempts))") |
||||||
|
_setupWebSocket() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func send(_ message: String) { |
||||||
|
self._webSocketTask?.send(.string(message)) { error in |
||||||
|
if let error = error { |
||||||
|
print("Error in sending message: \(error)") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func _ping() { |
||||||
|
self._webSocketTask?.sendPing { error in |
||||||
|
|
||||||
|
if let error: NSError = error as NSError?, |
||||||
|
error.domain == NSPOSIXErrorDomain && error.code == 57 { |
||||||
|
Logger.log("ping sent. Error?: \(error.localizedDescription) ") |
||||||
|
self._setupWebSocket() |
||||||
|
self._pingOk = false |
||||||
|
} else { |
||||||
|
self._pingOk = true |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func disconnect() { |
||||||
|
self._webSocketTask?.cancel(with: .goingAway, reason: nil) |
||||||
|
self._timer?.invalidate() |
||||||
|
} |
||||||
|
|
||||||
|
var pingStatus: Bool { |
||||||
|
return self._pingOk |
||||||
|
} |
||||||
|
|
||||||
|
var failure: Bool { |
||||||
|
return self._failure |
||||||
|
} |
||||||
|
|
||||||
|
var error: Error? { |
||||||
|
return self._error |
||||||
|
} |
||||||
|
|
||||||
|
var reconnectAttempts: Int { |
||||||
|
return self._reconnectAttempts |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,133 @@ |
|||||||
|
// |
||||||
|
// ApiCallTests.swift |
||||||
|
// LeStorageTests |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 15/02/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Testing |
||||||
|
@testable import LeStorage |
||||||
|
|
||||||
|
class Thing: SyncedModelObject, SyncedStorable, URLParameterConvertible { |
||||||
|
|
||||||
|
override required init() { |
||||||
|
super.init() |
||||||
|
} |
||||||
|
|
||||||
|
static func resourceName() -> String { return "thing" } |
||||||
|
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } |
||||||
|
static func filterByStoreIdentifier() -> Bool { return false } |
||||||
|
static var copyServerResponse: Bool = false |
||||||
|
static func storeParent() -> Bool { return false } |
||||||
|
|
||||||
|
var id: String = Store.randomId() |
||||||
|
var name: String = "" |
||||||
|
|
||||||
|
init(name: String) { |
||||||
|
self.name = name |
||||||
|
super.init() |
||||||
|
} |
||||||
|
|
||||||
|
required init(from decoder: any Decoder) throws { |
||||||
|
fatalError("init(from:) has not been implemented") |
||||||
|
} |
||||||
|
func copy(from other: any LeStorage.Storable) { |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
static func relationships() -> [LeStorage.Relationship] { return [] } |
||||||
|
|
||||||
|
func queryParameters(storeCenter: StoreCenter) -> [String : String] { |
||||||
|
return ["yeah?" : "god!"] |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
struct ApiCallTests { |
||||||
|
|
||||||
|
@Test func testApiCallProvisioning1() async throws { |
||||||
|
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main) |
||||||
|
|
||||||
|
let thing = Thing(name: "yeah") |
||||||
|
|
||||||
|
let _ = try await collection.sendInsertion(thing) |
||||||
|
|
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
if let apiCall = await collection.items.first { |
||||||
|
#expect(apiCall.method == .post) |
||||||
|
} |
||||||
|
|
||||||
|
thing.name = "woo" |
||||||
|
let _ = try await collection.sendUpdate(thing) |
||||||
|
await #expect(collection.items.count == 2) // one post and one put |
||||||
|
if let apiCall = await collection.items.first { |
||||||
|
#expect(apiCall.method == .post) |
||||||
|
} |
||||||
|
if let apiCall = await collection.items.last { |
||||||
|
#expect(apiCall.method == .put) |
||||||
|
} |
||||||
|
|
||||||
|
let _ = try await collection.sendDeletion(thing) |
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testApiCallProvisioning2() async throws { |
||||||
|
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main) |
||||||
|
|
||||||
|
let thing = Thing(name: "yeah") |
||||||
|
|
||||||
|
let _ = try await collection.sendUpdate(thing) |
||||||
|
|
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
if let apiCall = await collection.items.first { |
||||||
|
#expect(apiCall.method == .put) |
||||||
|
} |
||||||
|
|
||||||
|
thing.name = "woo" |
||||||
|
let _ = try await collection.sendUpdate(thing) |
||||||
|
let _ = try await collection.sendUpdate(thing) |
||||||
|
let _ = try await collection.sendUpdate(thing) |
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
if let apiCall = await collection.items.first { |
||||||
|
#expect(apiCall.method == .put) |
||||||
|
} |
||||||
|
|
||||||
|
let _ = try await collection.sendDeletion(thing) |
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testApiCallProvisioning3() async throws { |
||||||
|
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main) |
||||||
|
|
||||||
|
let thing = Thing(name: "yeah") |
||||||
|
|
||||||
|
let _ = try await collection.sendDeletion(thing) |
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
let _ = try await collection.sendDeletion(thing) |
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
let _ = try await collection.sendDeletion(thing) |
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testGetProvisioning() async throws { |
||||||
|
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main) |
||||||
|
|
||||||
|
try await collection.sendGetRequest(storeId: "1") |
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
try await collection.sendGetRequest(storeId: "1") |
||||||
|
await #expect(collection.items.count == 1) |
||||||
|
|
||||||
|
try await collection.sendGetRequest(storeId: "2") |
||||||
|
await #expect(collection.items.count == 2) |
||||||
|
|
||||||
|
try await collection.sendGetRequest(instance: Thing(name: "man!")) |
||||||
|
await #expect(collection.items.count == 3) |
||||||
|
|
||||||
|
try await collection.sendGetRequest(storeId: nil) |
||||||
|
await #expect(collection.items.count == 4) |
||||||
|
try await collection.sendGetRequest(storeId: nil) |
||||||
|
await #expect(collection.items.count == 4) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,87 @@ |
|||||||
|
// |
||||||
|
// CollectionsTests.swift |
||||||
|
// LeStorageTests |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 15/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Testing |
||||||
|
@testable import LeStorage |
||||||
|
|
||||||
|
class Car: ModelObject, Storable { |
||||||
|
|
||||||
|
var id: String = Store.randomId() |
||||||
|
|
||||||
|
static func resourceName() -> String { return "car" } |
||||||
|
func copy(from other: any LeStorage.Storable) { |
||||||
|
|
||||||
|
} |
||||||
|
static func relationships() -> [LeStorage.Relationship] { return [] } |
||||||
|
static func storeParent() -> Bool { return false } |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
class Boat: ModelObject, SyncedStorable { |
||||||
|
|
||||||
|
var id: String = Store.randomId() |
||||||
|
var lastUpdate: Date = Date() |
||||||
|
var sharing: LeStorage.SharingStatus? |
||||||
|
|
||||||
|
override required init() { |
||||||
|
super.init() |
||||||
|
} |
||||||
|
|
||||||
|
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } |
||||||
|
static func resourceName() -> String { return "boat" } |
||||||
|
static var copyServerResponse: Bool = false |
||||||
|
static func storeParent() -> Bool { return false } |
||||||
|
|
||||||
|
var storeId: String? { return nil } |
||||||
|
func copy(from other: any LeStorage.Storable) { |
||||||
|
|
||||||
|
} |
||||||
|
static func relationships() -> [LeStorage.Relationship] { return [] } |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
struct CollectionsTests { |
||||||
|
|
||||||
|
var cars: StoredCollection<Car> |
||||||
|
var boats: SyncedCollection<Boat> |
||||||
|
|
||||||
|
init() async { |
||||||
|
cars = await StoreCenter.main.mainStore.asyncLoadingStoredCollection(inMemory: true) |
||||||
|
boats = await StoreCenter.main.mainStore.asyncLoadingSynchronizedCollection(inMemory: true) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
@Test func testLoading() async { |
||||||
|
#expect(self.cars.hasLoaded) |
||||||
|
#expect(self.boats.hasLoaded) |
||||||
|
} |
||||||
|
|
||||||
|
@Test func differentiationTest() async throws { |
||||||
|
|
||||||
|
// Cars |
||||||
|
#expect(cars.count == 0) |
||||||
|
cars.addOrUpdate(instance: Car()) |
||||||
|
#expect(cars.count == 1) |
||||||
|
|
||||||
|
// Boats |
||||||
|
|
||||||
|
#expect(boats.count == 0) |
||||||
|
let oldApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self) |
||||||
|
#expect(oldApiCallCount == 0) |
||||||
|
|
||||||
|
boats.addOrUpdate(instance: Boat()) |
||||||
|
#expect(boats.count == 1) |
||||||
|
|
||||||
|
// Cars and boats |
||||||
|
cars.reset() |
||||||
|
boats.reset() |
||||||
|
#expect(cars.count == 0) |
||||||
|
#expect(boats.count == 0) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,110 @@ |
|||||||
|
// |
||||||
|
// LeStorageTests.swift |
||||||
|
// LeStorageTests |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 18/09/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Testing |
||||||
|
@testable import LeStorage |
||||||
|
|
||||||
|
class IntObject: ModelObject, Storable { |
||||||
|
|
||||||
|
static func resourceName() -> String { "int" } |
||||||
|
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] } |
||||||
|
static func storeParent() -> Bool { return false } |
||||||
|
|
||||||
|
var id: Int |
||||||
|
var name: String |
||||||
|
|
||||||
|
init(id: Int, name: String) { |
||||||
|
self.id = id |
||||||
|
self.name = name |
||||||
|
} |
||||||
|
func copy(from other: any LeStorage.Storable) { |
||||||
|
} |
||||||
|
|
||||||
|
static func relationships() -> [LeStorage.Relationship] { |
||||||
|
return [] |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
class StringObject: ModelObject, Storable { |
||||||
|
|
||||||
|
static func resourceName() -> String { "string" } |
||||||
|
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] } |
||||||
|
static func storeParent() -> Bool { return false } |
||||||
|
|
||||||
|
var id: String |
||||||
|
var name: String |
||||||
|
|
||||||
|
init(id: String, name: String) { |
||||||
|
self.id = id |
||||||
|
self.name = name |
||||||
|
} |
||||||
|
func copy(from other: any LeStorage.Storable) { |
||||||
|
} |
||||||
|
|
||||||
|
static func relationships() -> [LeStorage.Relationship] { |
||||||
|
return [] |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
struct IdentifiableTests { |
||||||
|
|
||||||
|
let intObjects: StoredCollection<IntObject> |
||||||
|
let stringObjects: StoredCollection<StringObject> |
||||||
|
|
||||||
|
init() { |
||||||
|
let dir = "test_" + String.random() |
||||||
|
let storeCenter: StoreCenter = StoreCenter(directoryName:dir) |
||||||
|
intObjects = storeCenter.mainStore.registerCollection() |
||||||
|
stringObjects = storeCenter.mainStore.registerCollection() |
||||||
|
} |
||||||
|
|
||||||
|
func ensureCollectionLoaded(_ collection: any SomeCollection) async throws { |
||||||
|
// Wait for the collection to finish loading |
||||||
|
// Adjust the timeout as needed |
||||||
|
let timeout = 5.0 // seconds |
||||||
|
let startTime = Date() |
||||||
|
|
||||||
|
while !collection.hasLoaded { |
||||||
|
// Check for timeout |
||||||
|
if Date().timeIntervalSince(startTime) > timeout { |
||||||
|
throw Error("Collection loading timed out") |
||||||
|
} |
||||||
|
// Wait a bit before checking again |
||||||
|
try await Task.sleep(for: .milliseconds(100)) |
||||||
|
} |
||||||
|
collection.reset() |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testIntIds() async throws { |
||||||
|
|
||||||
|
try await ensureCollectionLoaded(self.intObjects) |
||||||
|
|
||||||
|
let int = IntObject(id: 12, name: "test") |
||||||
|
self.intObjects.addOrUpdate(instance: int) |
||||||
|
|
||||||
|
if let search = intObjects.findById(12) { |
||||||
|
#expect(search.id == 12) |
||||||
|
} else { |
||||||
|
Issue.record("object is missing") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testStringIds() async throws { |
||||||
|
|
||||||
|
try await ensureCollectionLoaded(self.stringObjects) |
||||||
|
let string = StringObject(id: "coco", name: "name") |
||||||
|
self.stringObjects.addOrUpdate(instance: string) |
||||||
|
|
||||||
|
if let search = stringObjects.findById("coco") { |
||||||
|
#expect(search.id == "coco") |
||||||
|
} else { |
||||||
|
Issue.record("object is missing") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,141 @@ |
|||||||
|
// |
||||||
|
// StoredCollectionTests.swift |
||||||
|
// LeStorageTests |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 16/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Testing |
||||||
|
@testable import LeStorage |
||||||
|
|
||||||
|
struct Error: Swift.Error, CustomStringConvertible { |
||||||
|
let description: String |
||||||
|
|
||||||
|
init(_ description: String) { |
||||||
|
self.description = description |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
struct StoredCollectionTests { |
||||||
|
|
||||||
|
var collection: StoredCollection<MockStorable> |
||||||
|
|
||||||
|
init() async { |
||||||
|
collection = await StoreCenter.main.mainStore.asyncLoadingStoredCollection(inMemory: true) |
||||||
|
collection.reset() |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testInitialization() async throws { |
||||||
|
#expect(self.collection.hasLoaded) |
||||||
|
#expect(collection.items.count == 0) |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testAddOrUpdate() async throws { |
||||||
|
let item = MockStorable(id: "1", name: "Test") |
||||||
|
collection.addOrUpdate(instance: item) |
||||||
|
|
||||||
|
#expect(collection.items.count == 1) |
||||||
|
if let first = collection.items.first { |
||||||
|
#expect(first.id == "1") |
||||||
|
} else { |
||||||
|
Issue.record("missing record") |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
@Test func testDelete() async throws { |
||||||
|
let item = MockStorable(id: "1", name: "Test") |
||||||
|
collection.addOrUpdate(instance: item) |
||||||
|
#expect(collection.items.count == 1) |
||||||
|
|
||||||
|
collection.delete(instance: item) |
||||||
|
#expect(collection.items.isEmpty) |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testFindById() async throws { |
||||||
|
let item = MockStorable(id: "1", name: "Test") |
||||||
|
collection.addOrUpdate(instance: item) |
||||||
|
|
||||||
|
if let foundItem = collection.findById("1") { |
||||||
|
#expect(foundItem.id == "1") |
||||||
|
} else { |
||||||
|
Issue.record("missing item") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testDeleteById() async throws { |
||||||
|
let item = MockStorable(id: "1", name: "Test") |
||||||
|
collection.addOrUpdate(instance: item) |
||||||
|
|
||||||
|
collection.deleteByStringId("1") |
||||||
|
let search = collection.findById("1") |
||||||
|
#expect(search == nil) |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testAddOrUpdateMultiple() async throws { |
||||||
|
let items = [ |
||||||
|
MockStorable(id: "1", name: "Test1"), |
||||||
|
MockStorable(id: "2", name: "Test2"), |
||||||
|
] |
||||||
|
|
||||||
|
collection.addOrUpdate(contentOfs: items) |
||||||
|
#expect(collection.items.count == 2) |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testDeleteAll() async throws { |
||||||
|
let items = [ |
||||||
|
MockStorable(id: "1", name: "Test1"), |
||||||
|
MockStorable(id: "2", name: "Test2"), |
||||||
|
] |
||||||
|
|
||||||
|
collection.addOrUpdate(contentOfs: items) |
||||||
|
#expect(collection.items.count == 2) |
||||||
|
|
||||||
|
collection.clear() |
||||||
|
#expect(collection.items.isEmpty) |
||||||
|
} |
||||||
|
|
||||||
|
@Test func testRandomAccessCollection() async throws { |
||||||
|
let items = [ |
||||||
|
MockStorable(id: "1", name: "Test1"), |
||||||
|
MockStorable(id: "2", name: "Test2"), |
||||||
|
MockStorable(id: "3", name: "Test3"), |
||||||
|
] |
||||||
|
|
||||||
|
collection.addOrUpdate(contentOfs: items) |
||||||
|
|
||||||
|
#expect(collection.startIndex == 0) |
||||||
|
#expect(collection.endIndex == 3) |
||||||
|
|
||||||
|
if collection.count > 2 { |
||||||
|
#expect(collection[1].name == "Test2") |
||||||
|
} else { |
||||||
|
Issue.record("count not good") |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Mock Storable for testing purposes |
||||||
|
class MockStorable: ModelObject, Storable { |
||||||
|
|
||||||
|
var id: String = Store.randomId() |
||||||
|
var name: String |
||||||
|
|
||||||
|
init(id: String, name: String) { |
||||||
|
self.id = id |
||||||
|
self.name = name |
||||||
|
} |
||||||
|
|
||||||
|
static func resourceName() -> String { |
||||||
|
return "mocks" |
||||||
|
} |
||||||
|
func copy(from other: any LeStorage.Storable) { |
||||||
|
} |
||||||
|
|
||||||
|
static func relationships() -> [LeStorage.Relationship] { |
||||||
|
return [] |
||||||
|
} |
||||||
|
static func storeParent() -> Bool { return false } |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue