Refactor and cleanup for tests

sync3
Laurent 6 months ago
parent 369c71ba4e
commit e55f183053
  1. 4
      LeStorage.xcodeproj/project.pbxproj
  2. 27
      LeStorage/ApiCallCollection.swift
  3. 79
      LeStorage/Codables/SyncData.swift
  4. 31
      LeStorage/Services.swift
  5. 241
      LeStorage/StoreCenter.swift

@ -20,6 +20,7 @@
C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C471F2572DB10649006317F4 /* MockKeychainStore.swift */; };
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */; };
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; };
C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49774DE2DC4B3D7005CD239 /* SyncData.swift */; };
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; };
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; };
C4A47D4F2B6D280200ADC637 /* BaseCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */; };
@ -77,6 +78,7 @@
C471F2572DB10649006317F4 /* MockKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychainStore.swift; sourceTree = "<group>"; };
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.swift; sourceTree = "<group>"; };
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
C49774DE2DC4B3D7005CD239 /* SyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncData.swift; sourceTree = "<group>"; };
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; };
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCollection.swift; sourceTree = "<group>"; };
@ -219,6 +221,7 @@
C400D7222CC2AF560092237C /* GetSyncData.swift */,
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */,
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */,
C49774DE2DC4B3D7005CD239 /* SyncData.swift */,
);
path = Codables;
sourceTree = "<group>";
@ -363,6 +366,7 @@
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* BaseCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,
C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */,
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */,
C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */,
C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */,

@ -236,9 +236,9 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
try await self._executeGetCall(apiCall: apiCall)
} else {
let results = try await self._executeApiCalls(batch)
let results: [OperationResult<T>] = try await self._executeApiCalls(batch)
if T.copyServerResponse {
let instances = results.compactMap { $0.data }
let instances: [T] = results.compactMap { $0.data }
self.storeCenter.updateLocalInstances(instances)
}
}
@ -248,15 +248,28 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
}
}
fileprivate func _executeGetCall(apiCall: ApiCall<T>) async throws {
@discardableResult func _executeGetCall(apiCall: ApiCall<T>) async throws -> Data {
let data = try await self.storeCenter.executeGet(apiCall: apiCall)
if T.self == GetSyncData.self {
let _: Empty = try await self.storeCenter.executeGet(apiCall: apiCall)
let syncData = try SyncData(data: data, storeCenter: self.storeCenter)
await self.storeCenter.synchronizeContent(syncData)
} else {
let results: [T] = try await self.storeCenter.executeGet(apiCall: apiCall)
let results: [T] = try self._decode(data: data)
await self.storeCenter.itemsRetrieved(results, storeId: apiCall.storeId, clear: apiCall.option != .additive)
}
return data
}
fileprivate func _decode<V: Decodable>(data: Data) throws -> V {
if !(V.self is Empty?.Type || V.self is Empty.Type) {
return try JSON.decoder.decode(V.self, from: data)
} else {
return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!)
}
}
/// Wait for an exponentionnaly long time depending on the number of attemps
fileprivate func _wait() async {
@ -376,11 +389,11 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
await self._batchExecution()
}
func executeSingleGet(instance: T) async where T : URLParameterConvertible {
func executeSingleGet(instance: T) async throws -> Data where T : URLParameterConvertible {
let call = self._createCall(.get, instance: instance, option: .none)
call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter)
self._addCallToWaitingList(call)
await self._batchExecution()
return try await self._executeGetCall(apiCall: call)
}
fileprivate func _prepareCalls(batch: OperationBatch<T>) {

@ -0,0 +1,79 @@
//
// 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 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 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["revocation_parents"] 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
}
}

@ -81,9 +81,9 @@ public class Services {
/// - Parameters:
/// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runGetApiCallRequest<T: SyncedStorable, V: Decodable>(
fileprivate func _runGetApiCallRequest<T: SyncedStorable>(
_ request: URLRequest, apiCall: ApiCall<T>
) async throws -> V {
) async throws -> Data {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
@ -95,11 +95,6 @@ public class Services {
switch statusCode {
case 200..<300: // success
try await self.storeCenter.deleteApiCallById(type: T.self, id: apiCall.id)
if T.self == GetSyncData.self {
await self.storeCenter.synchronizeContent(task.0)
}
default: // error
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
@ -123,7 +118,7 @@ public class Services {
Logger.w(message)
}
return try self._decode(data: task.0)
return task.0 //try self._decode(data: task.0)
}
@ -420,15 +415,15 @@ public class Services {
return request
}
/// Starts a request to retrieve the synchronization updates
/// - Parameters:
/// - since: The date from which updates are retrieved
func synchronizeLastUpdates(since: Date?) async throws {
let request = try self._getSyncLogRequest(since: since)
if let data = try await self._runRequest(request) {
await self.storeCenter.synchronizeContent(data)
}
}
// /// Starts a request to retrieve the synchronization updates
// /// - Parameters:
// /// - since: The date from which updates are retrieved
// func synchronizeLastUpdates(since: Date?) async throws {
// let request = try self._getSyncLogRequest(since: since)
// if let data = try await self._runRequest(request) {
// await self.storeCenter.synchronizeContent(data)
// }
// }
/// Returns the URLRequest for an ApiCall
/// - Parameters:
@ -520,7 +515,7 @@ public class Services {
}
/// Executes an ApiCall
func runGetApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
func runGetApiCall<T: SyncedStorable>(_ apiCall: ApiCall<T>) async throws -> Data {
let request = try self._syncGetRequest(from: apiCall)
return try await self._runGetApiCallRequest(request, apiCall: apiCall)
}

@ -19,6 +19,7 @@ public class StoreCenter {
/// A dictionary of Stores associated to their id
fileprivate var _stores: [String: Store] = [:]
/// Returns a default Store instance
lazy var mainStore: Store = { Store(storeCenter: self) }()
/// A KeychainStore object used to store the user's token
@ -61,6 +62,7 @@ public class StoreCenter {
/// The URL manager
fileprivate var _urlManager: URLManager? = nil
/// Used for testing, gives the project name to retrieve classes from names
var classProject: String? = nil
init(directoryName: String? = nil) {
@ -72,6 +74,11 @@ public class StoreCenter {
self.loadApiCallCollection(type: GetSyncData.self)
if let directoryName {
self._settingsStorage = MicroStorage(
fileName: "\(directoryName)/settings.json")
}
NetworkMonitor.shared.onConnectionEstablished = {
self._resumeApiCalls()
// self._configureWebSocket()
@ -442,7 +449,7 @@ public class StoreCenter {
// }
/// Executes an API call
func executeGet<T: SyncedStorable, V: Decodable>(apiCall: ApiCall<T>) async throws -> V {
func executeGet<T: SyncedStorable>(apiCall: ApiCall<T>) async throws -> Data {
return try await self.service().runGetApiCall(apiCall)
}
@ -563,7 +570,7 @@ public class StoreCenter {
}
func testSynchronizeOnceAsync() async throws {
func testSynchronizeOnceAsync() async throws -> Data {
guard self.isAuthenticated else {
throw StoreError.missingToken
}
@ -572,7 +579,7 @@ public class StoreCenter {
let getSyncData = GetSyncData()
getSyncData.date = lastSync
await syncGetCollection.executeSingleGet(instance: getSyncData)
return try await syncGetCollection.executeSingleGet(instance: getSyncData)
}
func sendGetRequest<T: SyncedStorable>(_ type: T.Type, storeId: String?, clear: Bool) async throws {
@ -599,69 +606,33 @@ public class StoreCenter {
Logger.w("data unrecognized: \(string)")
return
}
try await self._parseSyncUpdates(json, shared: true)
let array = try self.decodeDictionary(json)
await self._syncAddOrUpdate(array, shared: true)
} catch {
Logger.error(error)
}
}
/// Processes the data coming from a sync request
@MainActor func synchronizeContent(_ data: Data) {
do {
guard
let json = try JSONSerialization.jsonObject(with: data, options: [])
as? [String: Any]
else {
Logger.w("data unrecognized")
return
}
if let updates = json["updates"] as? [String: Any] {
try self._parseSyncUpdates(updates)
}
if let deletions = json["deletions"] as? [String: Any] {
try self._parseSyncDeletions(deletions)
}
if let updates = json["grants"] as? [String: Any] {
try self._parseSyncUpdates(updates, shared: true)
}
if let revocations = json["revocations"] as? [String: Any] {
try self._parseSyncRevocations(revocations, parents: json["revocation_parents"] as? [[String: Any]])
}
// Data access events
if let rs = json["relationship_sets"] as? [String: Any] {
try self._parseSyncUpdates(rs)
}
if let rr = json["relationship_removals"] as? [String: Any] {
try self._parseSyncDeletions(rr)
}
if let srs = json["shared_relationship_sets"] as? [String: Any] {
try self._parseSyncUpdates(srs, shared: true)
}
if let srm = json["shared_relationship_removals"] as? [String: Any] {
self._synchronizationRevoke(items: srm)
@MainActor func synchronizeContent(_ syncData: SyncData) {
self._syncAddOrUpdate(syncData.updates)
self._syncDelete(syncData.deletions)
self._syncAddOrUpdate(syncData.grants, shared: true)
self.syncRevoke(syncData.revocations, parents: syncData.revocationParents)
self._syncAddOrUpdate(syncData.relationshipSets)
self._syncDelete(syncData.relationshipRemovals)
self._syncAddOrUpdate(syncData.sharedRelationshipSets)
self._syncRevoke(syncData.sharedRelationshipRemovals)
if let dateString = syncData.date {
Logger.log("Sets sync date = \(dateString)")
self._settingsStorage.update { settings in
settings.lastSynchronization = dateString
}
if let dateString = json["date"] as? String {
Logger.log("Sets sync date = \(dateString)")
self._settingsStorage.update { settings in
settings.lastSynchronization = dateString
}
}
} catch {
self.log(message: error.localizedDescription)
Logger.error(error)
}
NotificationCenter.default.post(
name: NSNotification.Name.LeStorageDidSynchronize, object: self)
@ -669,99 +640,50 @@ public class StoreCenter {
/// Processes data that should be inserted or updated inside the app
/// - Parameters:
/// - updates: the server updates
/// - updateArrays: the server updates
/// - shared: indicates if the content should be flagged as shared
@MainActor func _parseSyncUpdates(_ updates: [String: Any], shared: Bool = false) throws {
for (className, updateData) in updates {
guard let updateArray = updateData as? [[String: Any]] else {
Logger.w("Invalid update data for \(className)")
continue
}
Logger.log(">>> UPDATE \(updateArray.count) \(className)")
let type = try self.classFromName(className)
for updateItem in updateArray {
do {
let jsonData = try JSONSerialization.data(
withJSONObject: updateItem, options: [])
let decodedObject = try JSON.decoder.decode(type, from: jsonData)
// Logger.log(">>> \(decodedObject.lastUpdate.timeIntervalSince1970) : \(decodedObject.id)")
let storeId: String? = decodedObject.getStoreId()
self.synchronizationAddOrUpdate(decodedObject, storeId: storeId, shared: shared)
} catch {
Logger.w("Issue with json decoding: \(updateItem)")
Logger.error(error)
}
@MainActor func _syncAddOrUpdate(_ updateArrays: [SyncedStorableArray], shared: Bool = false) {
for updateArray in updateArrays {
for item in updateArray.items {
let storeId: String? = item.getStoreId()
self.synchronizationAddOrUpdate(item, storeId: storeId, shared: shared)
}
}
}
/// Processes data that should be deleted inside the app
fileprivate func _parseSyncDeletions(_ deletions: [String: Any]) throws {
for (className, deleteData) in deletions {
guard let deletedItems = deleteData as? [Any] else {
Logger.w("Invalid update data for \(className)")
continue
}
for deleted in deletedItems {
do {
let data = try JSONSerialization.data(withJSONObject: deleted, options: [])
let deletedObject = try JSON.decoder.decode(ObjectIdentifier.self, from: data)
self.synchronizationDelete(id: deletedObject.modelId, model: className, storeId: deletedObject.storeId)
} catch {
Logger.error(error)
}
fileprivate func _syncDelete(_ deletionArrays: [ObjectIdentifierArray]) {
for deletionArray in deletionArrays {
for deletedObject in deletionArray.items {
self.synchronizationDelete(id: deletedObject.modelId, type: deletionArray.type, storeId: deletedObject.storeId)
}
}
}
/// Processes data that has been revoked
fileprivate func _parseSyncRevocations(_ deletions: [String: Any], parents: [[String: Any]]?) throws {
for (className, revocationData) in deletions {
guard let revokedItems = revocationData as? [Any] else {
Logger.w("Invalid update data for \(className)")
continue
}
for revoked in revokedItems {
do {
let data = try JSONSerialization.data(withJSONObject: revoked, options: [])
let revokedObject = try JSON.decoder.decode(ObjectIdentifier.self, from: data)
self.synchronizationDelete(id: revokedObject.modelId, model: className, storeId: revokedObject.storeId)
} catch {
Logger.error(error)
}
fileprivate func syncRevoke(_ revokedArrays: [ObjectIdentifierArray], parents: [[ObjectIdentifierArray]]) {
self._syncRevoke(revokedArrays)
for revokedArray in revokedArrays {
for revoked in revokedArray.items {
self.synchronizationDelete(id: revoked.modelId, type: revokedArray.type, storeId: revoked.storeId) // or synchronizationRevoke ?
}
}
if let parents {
for level in parents {
self._synchronizationRevoke(items: level)
}
for level in parents {
self._syncRevoke(level)
}
}
fileprivate func _synchronizationRevoke(items: [String: Any]) {
for (className, parentData) in items {
guard let parentItems = parentData as? [Any] else {
Logger.w("Invalid update data for \(className): \(parentData)")
continue
}
for parentItem in parentItems {
do {
let data = try JSONSerialization.data(withJSONObject: parentItem, options: [])
let revokedObject = try JSON.decoder.decode(ObjectIdentifier.self, from: data)
self.synchronizationRevoke(id: revokedObject.modelId, model: className, storeId: revokedObject.storeId)
} catch {
Logger.error(error)
}
fileprivate func _syncRevoke(_ revokeArrays: [ObjectIdentifierArray]) {
for revokeArray in revokeArrays {
for revoked in revokeArray.items {
self.synchronizationRevoke(id: revoked.modelId, type: revokeArray.type, storeId: revoked.storeId)
}
}
@ -814,11 +736,11 @@ public class StoreCenter {
}
/// Deletes an instance with the given parameters
func synchronizationDelete(id: String, model: String, storeId: String?) {
func synchronizationDelete<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) {
DispatchQueue.main.async {
do {
let type = try self.classFromName(model)
// let type = try self.classFromName(model)
try self._store(id: storeId).deleteNoSync(type: type, id: id)
} catch {
Logger.error(error)
@ -828,11 +750,11 @@ public class StoreCenter {
}
/// Revokes a data that has been shared with the user
func synchronizationRevoke(id: String, model: String, storeId: String?) {
func synchronizationRevoke<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) {
DispatchQueue.main.async {
do {
let type = try self.classFromName(model)
// let type = try self.classFromName(model)
if self._instanceShared(id: id, type: type) {
let count = self.mainStore.referenceCount(type: type, id: id)
if count == 0 {
@ -870,6 +792,49 @@ public class StoreCenter {
self._deleteLogs.addOrUpdate(instance: dataLog)
}
// MARK: - Sync data conversion
func decodeObjectIdentifierDictionary(_ dictionary: [String: Any]) throws -> [ObjectIdentifierArray] {
var objectIdentifierArray: [ObjectIdentifierArray] = []
for (className, dataArray) in dictionary {
guard let array = dataArray as? [[String: Any]] else {
Logger.w("Invalid update data for \(className)")
continue
}
let type = try self.classFromName(className)
let decodedArray = try self._decodeArray(type: ObjectIdentifier.self, array: array)
objectIdentifierArray.append(ObjectIdentifierArray(type: type, items: decodedArray))
}
return objectIdentifierArray
}
func decodeDictionary(_ dictionary: [String: Any]) throws -> [SyncedStorableArray] {
var syncedStorableArray: [SyncedStorableArray] = []
for (className, dataArray) in dictionary {
guard let array = dataArray as? [[String: Any]] else {
Logger.w("Invalid update data for \(className)")
continue
}
Logger.log(">>> UPDATE \(array.count) \(className)")
let type = try self.classFromName(className)
let decodedArray = try self._decodeArray(type: type, array: array)
syncedStorableArray.append(SyncedStorableArray(type: type, items: decodedArray))
}
return syncedStorableArray
}
fileprivate func _decodeArray<T: Decodable>(type: T.Type, array: [[String : Any]]) throws -> [T] {
let jsonData = try JSONSerialization.data(withJSONObject: array, options: [])
return try JSON.decoder.decode([T].self, from: jsonData)
}
// MARK: - Miscellanous
/// Returns the count of api calls for a Type

Loading…
Cancel
Save