Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
22753ae230 | 1 year ago |
@ -1,55 +0,0 @@ |
|||||||
<?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> |
|
||||||
@ -1,11 +0,0 @@ |
|||||||
|
|
||||||
### 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 |
|
||||||
|
|
||||||
@ -1,83 +0,0 @@ |
|||||||
// |
|
||||||
// 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) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,44 +0,0 @@ |
|||||||
// |
|
||||||
// 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") |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,57 +0,0 @@ |
|||||||
// |
|
||||||
// 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 [] } |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,34 +0,0 @@ |
|||||||
// |
|
||||||
// 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 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,83 +0,0 @@ |
|||||||
// |
|
||||||
// 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 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,60 +0,0 @@ |
|||||||
// |
|
||||||
// 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() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,18 +0,0 @@ |
|||||||
// |
|
||||||
// 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") |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,66 +0,0 @@ |
|||||||
// |
|
||||||
// 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) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,31 +0,0 @@ |
|||||||
// |
|
||||||
// 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
@ -1,64 +0,0 @@ |
|||||||
// |
|
||||||
// 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() |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,478 +0,0 @@ |
|||||||
// |
|
||||||
// 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) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,57 +0,0 @@ |
|||||||
// |
|
||||||
// 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 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,50 +0,0 @@ |
|||||||
// |
|
||||||
// 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 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,33 +0,0 @@ |
|||||||
// |
|
||||||
// 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 |
|
||||||
}() |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,26 +0,0 @@ |
|||||||
// |
|
||||||
// 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: "&") |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,12 +0,0 @@ |
|||||||
// |
|
||||||
// Formatter.swift |
|
||||||
// LeStorage |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 30/10/2024. |
|
||||||
// |
|
||||||
|
|
||||||
class Formatter { |
|
||||||
|
|
||||||
static let number: NumberFormatter = NumberFormatter() |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,44 +0,0 @@ |
|||||||
// |
|
||||||
// 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 |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
// |
|
||||||
// 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()! }) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,24 +0,0 @@ |
|||||||
// |
|
||||||
// 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" |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,39 +0,0 @@ |
|||||||
// |
|
||||||
// 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://" |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,151 +0,0 @@ |
|||||||
// |
|
||||||
// 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 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,133 +0,0 @@ |
|||||||
// |
|
||||||
// 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) |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,87 +0,0 @@ |
|||||||
// |
|
||||||
// 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) |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,110 +0,0 @@ |
|||||||
// |
|
||||||
// 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") |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,141 +0,0 @@ |
|||||||
// |
|
||||||
// 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