Add / update / delete sync obtained!

sync2
Laurent 1 year ago
parent b5b32892dc
commit 7a21f27550
  1. 4
      LeStorage.xcodeproj/project.pbxproj
  2. 65
      LeStorage/ApiCallCollection.swift
  3. 42
      LeStorage/Codables/ApiCall.swift
  4. 16
      LeStorage/Codables/GetSyncData.swift
  5. 24
      LeStorage/Codables/SyncResponse.swift
  6. 4
      LeStorage/ModelObject.swift
  7. 98
      LeStorage/Services.swift
  8. 3
      LeStorage/Storable.swift
  9. 8
      LeStorage/Store.swift
  10. 100
      LeStorage/StoreCenter.swift
  11. 37
      LeStorage/StoredCollection+Sync.swift
  12. 70
      LeStorage/StoredCollection.swift
  13. 4
      LeStorage/SyncedStorable.swift
  14. 42
      LeStorage/Utils/Codable+Extensions.swift
  15. 3
      LeStorage/Utils/Errors.swift
  16. 12
      LeStorage/Utils/Formatter.swift

@ -14,6 +14,7 @@
C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; };
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; };
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; };
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AAE22CD2466400D76CD2 /* Formatter.swift */; };
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.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 */; };
@ -60,6 +61,7 @@
C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = "<group>"; };
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; };
C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = "<group>"; };
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.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>"; };
@ -156,6 +158,7 @@
C4A47D582B6D352900ADC637 /* Utils */ = {
isa = PBXGroup;
children = (
C467AAE22CD2466400D76CD2 /* Formatter.swift */,
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */,
C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */,
C4D477962CB66EEA0077713D /* Date+Extensions.swift */,
@ -317,6 +320,7 @@
C400D7252CC2B5CF0092237C /* SyncResponse.swift in Sources */,
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */,
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */,
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */,
C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */,
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */,
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */,

@ -51,7 +51,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Reschedule Api calls if not empty
func loadFromFile() throws {
try self._decodeJSONFile()
self.rescheduleApiCallsIfNecessary()
// self.rescheduleApiCallsIfNecessary()
}
/// Returns the file URL of the collection
@ -223,8 +223,21 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Returns an APICall instance for the Storable [instance] and an HTTP [method]
/// The method updates existing calls or creates a new one
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? {
fileprivate func _call(method: HTTPMethod, instance: T? = nil) throws -> ApiCall<T>? {
if let instance {
return try self._callForInstance(method, instance: instance)
} else {
if self.items.contains(where: { $0.method == .get }) {
return nil
} else {
return try self._createCall(.get)
}
}
}
fileprivate func _callForInstance(_ method: HTTPMethod, instance: T) throws -> ApiCall<T>? {
if let existingCall = self.items.first(where: { $0.dataId == instance.stringId }) {
switch method {
case .delete:
@ -232,21 +245,25 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
if existingCall.method == HTTPMethod.post {
return nil // if the post has not been done, we can just stop here
} else {
return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete
return try self._createCall(method, instance: instance) // otherwise it's a put and we want to send the delete
}
default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values
default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values
existingCall.body = try instance.jsonString()
return existingCall
}
} else {
return try self._createCall(instance, method: method)
return try self._createCall(method, instance: instance)
}
}
/// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> {
let jsonString = try instance.jsonString()
return ApiCall(method: method, dataId: instance.stringId, body: jsonString)
fileprivate func _createCall(_ method: HTTPMethod, instance: T? = nil) throws -> ApiCall<T> {
if let instance {
let jsonString = try instance.jsonString()
return ApiCall(method: method, dataId: instance.stringId, body: jsonString)
} else {
return ApiCall(method: .get)
}
}
/// Prepares a call for execution by updating its properties and adding it to its collection for storage
@ -256,10 +273,22 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
self.addOrUpdate(apiCall)
}
/// Sends an insert api call for the provided [instance]
func sendGetIfNecessary(instance: T) async throws where T : URLParameterConvertible {
do {
let apiCall = ApiCall<T>(method: .get)
apiCall.urlParameters = instance.queryParameters()
let _: Empty? = try await self._prepareAndSendCall(apiCall)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
/// Sends an insert api call for the provided [instance]
func sendInsertion(_ instance: T) async throws -> T? {
do {
return try await self._synchronize(instance, method: HTTPMethod.post)
return try await self._sendServerRequest(HTTPMethod.post, instance: instance)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
@ -271,7 +300,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Sends an update api call for the provided [instance]
func sendUpdate(_ instance: T) async throws -> T? {
do {
return try await self._synchronize(instance, method: HTTPMethod.put)
return try await self._sendServerRequest(HTTPMethod.put, instance: instance)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
@ -282,7 +311,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Sends an delete api call for the provided [instance]
func sendDeletion(_ instance: T) async throws {
do {
let _: Empty? = try await self._synchronize(instance, method: HTTPMethod.delete)
let _: Empty? = try await self._sendServerRequest(HTTPMethod.delete, instance: instance)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
@ -291,15 +320,21 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
}
/// Initiates the process of sending the data with the server
fileprivate func _synchronize<V: Decodable>(_ instance: T, method: HTTPMethod) async throws -> V? {
if let apiCall = try self._callForInstance(instance, method: method) {
try self._prepareCall(apiCall: apiCall)
return try await self._executeApiCall(apiCall)
fileprivate func _sendServerRequest<V: Decodable>(_ method: HTTPMethod, instance: T? = nil) async throws -> V? {
if let apiCall = try self._call(method: method, instance: instance) {
return try await self._prepareAndSendCall(apiCall)
// try self._prepareCall(apiCall: apiCall)
// return try await self._executeApiCall(apiCall)
} else {
return nil
}
}
fileprivate func _prepareAndSendCall<V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V? {
try self._prepareCall(apiCall: apiCall)
return try await self._executeApiCall(apiCall)
}
/// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert
// fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws -> T {

@ -24,7 +24,7 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
/// Creation date of the call
var creationDate: Date? = Date()
/// The HTTP method of the call: post...
/// The HTTP method of the call
var method: HTTPMethod
/// The content of the call
@ -39,7 +39,10 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
/// The date of the last execution
var lastAttemptDate: Date = Date()
init(method: HTTPMethod, dataId: String, body: String) {
/// The parameters to add in the URL to obtain : "?p1=v1&p2=v2"
var urlParameters: [String : String]? = nil
init(method: HTTPMethod, dataId: String? = nil, body: String? = nil) {
self.method = method
self.dataId = dataId
self.body = body
@ -49,4 +52,39 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
fatalError("should not happen")
}
func formattedURLParameters() -> String? {
return self.urlParameters?.toQueryString()
}
func urlExtension() -> String {
switch self.method {
case HTTPMethod.put, HTTPMethod.delete:
return T.path(id: self.dataId)
case HTTPMethod.post:
return T.path()
case HTTPMethod.get:
if let parameters = self.urlParameters?.toQueryString() {
return T.path() + parameters
} else {
return T.path()
}
}
}
}
fileprivate extension Dictionary where Key == String, Value == String {
func toQueryString() -> String? {
guard !self.isEmpty else {
return nil
}
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: "&")
}
}

@ -7,11 +7,12 @@
import Foundation
class GetSyncData: ModelObject, SyncedStorable {
class GetSyncData: ModelObject, SyncedStorable, URLParameterConvertible {
static func filterByStoreIdentifier() -> Bool { return false }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
var lastUpdate: Date = Date()
var lastUpdate: Date = Date.distantPast
static func resourceName() -> String {
return "data"
@ -22,4 +23,15 @@ class GetSyncData: ModelObject, SyncedStorable {
self.lastUpdate = getSyncData.lastUpdate
}
func queryParameters() -> [String : String] {
return ["last_update" : self._formattedLastUpdate]
}
fileprivate var _formattedLastUpdate: String {
let formattedDate = ISO8601DateFormatter().string(from: self.lastUpdate)
let encodedDate =
formattedDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return encodedDate.replacingOccurrences(of: "+", with: "%2B")
}
}

@ -26,7 +26,7 @@ struct SyncResponse: Codable {
var updatesDict = [String: [AnyCodable]]()
for key in updatesContainer.allKeys {
let swiftClass = try SyncResponse._classFromClassName(key.stringValue)
// let swiftClass = try SyncResponse._classFromClassName(key.stringValue)
let decodedArray = try updatesContainer.decode([AnyCodable].self, forKey: key)
let typedArray = decodedArray.compactMap { $0.value as? AnyCodable }
updatesDict[key.stringValue] = typedArray
@ -59,17 +59,17 @@ struct SyncResponse: Codable {
}
}
fileprivate static func _classFromClassName(_ className: String) throws -> Codable.Type {
let fullClassName = "PadelClub.\(className)"
let modelClass: AnyClass? = NSClassFromString(fullClassName)
if let type = modelClass as? Codable.Type {
return type
} else {
throw LeStorageError.cantFindClassFromName(name: className)
}
}
// fileprivate static func _classFromClassName(_ className: String) throws -> Codable.Type {
//
// let fullClassName = "PadelClub.\(className)"
// let modelClass: AnyClass? = NSClassFromString(fullClassName)
// if let type = modelClass as? Codable.Type {
// return type
// } else {
// throw LeStorageError.cantFindClassFromName(name: className)
// }
//
// }
}

@ -27,10 +27,6 @@ open class ModelObject: NSObject {
static var relationshipNames: [String] = []
open func hasBeenDeleted() {
}
// // MARK: - Codable
//
// enum CodingKeys: CodingKey {

@ -49,20 +49,20 @@ public class Services {
/// The base API URL to send requests
fileprivate(set) var baseURL: String
fileprivate var jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .iso8601
return encoder
}()
fileprivate var jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
// fileprivate var jsonEncoder: JSONEncoder = {
// let encoder = JSONEncoder()
// encoder.keyEncodingStrategy = .convertToSnakeCase
// encoder.outputFormatting = .prettyPrinted
// encoder.dateEncodingStrategy = .iso8601
// return encoder
// }()
//
// fileprivate var jsonDecoder: JSONDecoder = {
// let decoder = JSONDecoder()
// decoder.keyDecodingStrategy = .convertFromSnakeCase
// decoder.dateDecodingStrategy = .iso8601
// return decoder
// }()
// MARK: - Base
@ -75,7 +75,7 @@ public class Services {
async throws -> U
{
var request = try self._baseRequest(call: serviceCall)
request.httpBody = try jsonEncoder.encode(payload)
request.httpBody = try JSON.encoder.encode(payload)
return try await _runRequest(request)
}
@ -99,7 +99,7 @@ public class Services {
try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id)
if T.self == GetSyncData.self {
StoreCenter.main.synchronizeContent(task.0, decoder: self.jsonDecoder)
StoreCenter.main.synchronizeContent(task.0)
}
default: // error
@ -131,9 +131,9 @@ public class Services {
fileprivate func _decode<V: Decodable>(data: Data) throws -> V {
if !(V.self is Empty?.Type || V.self is Empty.Type) {
return try jsonDecoder.decode(V.self, from: data)
return try JSON.decoder.decode(V.self, from: data)
} else {
return try jsonDecoder.decode(V.self, from: "{}".data(using: .utf8)!)
return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!)
}
}
@ -277,7 +277,7 @@ public class Services {
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try jsonEncoder.encode(payload)
request.httpBody = try JSON.encoder.encode(payload)
let token = try self.keychainStore.getValue()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
@ -288,33 +288,41 @@ public class Services {
/// Returns the URLRequest for an ApiCall
/// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _syncRequest<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest
{
fileprivate func _syncRequest<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest {
let urlString = baseURL + "data/"
var urlString = baseURL + "data/"
if let urlParameters = apiCall.formattedURLParameters() {
urlString.append(urlParameters)
}
guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString)
}
guard let body = apiCall.body, let bodyData = body.data(using: .utf8) else {
throw ServiceError.cantDecodeData(content: apiCall.body)
}
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.post.rawValue
if apiCall.method == .get {
request.httpMethod = HTTPMethod.get.rawValue
} else {
request.httpMethod = HTTPMethod.post.rawValue
}
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// moyennement fan de decoder pour recoder derriere
let data = try jsonDecoder.decode(T.self, from: bodyData)
let modelName = String(describing: T.self)
let payload = SyncPayload(
operation: apiCall.method.rawValue,
modelName: modelName,
data: data,
storeId: data.getStoreId())
request.httpBody = try jsonEncoder.encode(payload)
if let body = apiCall.body {
if let data = body.data(using: .utf8) {
let object = try JSON.decoder.decode(T.self, from: data)
let modelName = String(describing: T.self)
let payload = SyncPayload(
operation: apiCall.method.rawValue,
modelName: modelName,
data: object,
storeId: object.getStoreId())
request.httpBody = try JSON.encoder.encode(payload)
} else {
throw ServiceError.cantDecodeData(resource: T.resourceName(), method: apiCall.method.rawValue, content: apiCall.body)
}
}
if self._isTokenRequired(type: T.self, method: apiCall.method) {
let token = try self.keychainStore.getValue()
@ -371,7 +379,7 @@ public class Services {
print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode {
case 200..<300: // success
StoreCenter.main.synchronizeContent(task.0, decoder: self.jsonDecoder)
StoreCenter.main.synchronizeContent(task.0)
default: // error
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
@ -463,12 +471,8 @@ public class Services {
/// - apiCall: an instance of ApiCall to build to URL
fileprivate func _url<T: Storable>(from apiCall: ApiCall<T>) throws -> URL {
var stringURL: String = self.baseURL
switch apiCall.method {
case HTTPMethod.put, HTTPMethod.delete:
stringURL += T.path(id: apiCall.dataId)
default:
stringURL += T.path()
}
stringURL += apiCall.urlExtension()
if let url = URL(string: stringURL) {
return url
} else {
@ -493,7 +497,7 @@ public class Services {
var postRequest = try self._baseRequest(call: requestTokenCall)
let deviceId = StoreCenter.main.deviceId()
let credentials = Credentials(username: username, password: password, deviceId: deviceId)
postRequest.httpBody = try jsonEncoder.encode(credentials)
postRequest.httpBody = try JSON.encoder.encode(credentials)
let response: AuthResponse = try await self._runRequest(postRequest)
self._storeToken(username: username, token: response.token)
return response.token
@ -580,7 +584,7 @@ public class Services {
public func forgotPassword(email: String) async throws {
var postRequest = try self._baseRequest(
servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false)
postRequest.httpBody = try jsonEncoder.encode(Email(email: email))
postRequest.httpBody = try JSON.encoder.encode(Email(email: email))
let response: Email = try await self._runRequest(postRequest)
Logger.log("response = \(response)")
}

@ -30,9 +30,6 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
// static var relationshipNames: [String] { get }
/// A method called after the instance has been deleted from its StoredCollection
func hasBeenDeleted()
func copy(from other: any Storable)
}

@ -199,12 +199,18 @@ open class Store {
func deleteNoSync<T: Storable>(instance: T) {
do {
let collection: StoredCollection<T> = try self.collection()
try collection.deleteById(instance.id)
collection.delete(instance: instance)
} catch {
Logger.error(error)
}
}
/// Calls deleteById from the collection corresponding to the instance
func deleteNoSync<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: StoredCollection<T> = try self.collection()
collection.deleteByStringIdNoSync(id)
}
// MARK: - Write
/// Returns the directory URL of the store

@ -41,6 +41,9 @@ public class StoreCenter {
/// The dictionary of registered StoredCollections
fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:]
/// A collection of DataLog objects, used for the synchronization
// fileprivate var _syncGetRequests: ApiCallCollection<GetSyncData>
/// A collection of DataLog objects, used for the synchronization
fileprivate var _dataLogs: StoredCollection<DataLog>
@ -54,8 +57,12 @@ public class StoreCenter {
fileprivate var _blackListedUserName: [String] = []
init() {
// self._syncGetRequests = ApiCallCollection()
self._dataLogs = Store.main.registerCollection()
self._setupNotifications()
self.loadApiCallCollection(type: GetSyncData.self)
NetworkMonitor.shared.onConnectionEstablished = {
self._resumeApiCalls()
@ -212,7 +219,7 @@ public class StoreCenter {
if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection<T> {
return collection
}
throw StoreError.collectionNotRegistered(type: T.resourceName())
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName())
}
/// Deletes an ApiCall, identifying it by dataId
@ -361,11 +368,22 @@ public class StoreCenter {
}
public func synchronizeLastUpdates() async throws {
let lastSync: Date? = self._settingsStorage.item.lastSynchronization
try await self._services?.synchronizeLastUpdates(since: lastSync)
if let lastSync = self._settingsStorage.item.lastSynchronization {
let getSyncData = GetSyncData()
getSyncData.lastUpdate = lastSync
let sync: ApiCallCollection<GetSyncData> = try self.apiCallCollection()
try await sync.sendGetIfNecessary(instance: getSyncData)
} else {
Logger.w("Can't sync due to missing saved date")
}
// let lastSync: Date? = self._settingsStorage.item.lastSynchronization
// try await self._services?.synchronizeLastUpdates(since: lastSync)
}
func synchronizeContent(_ data: Data, decoder: JSONDecoder) {
func synchronizeContent(_ data: Data) {
do {
guard
@ -378,7 +396,7 @@ public class StoreCenter {
if let updates = json["updates"] as? [String: Any] {
do {
try self._parseSyncUpdates(updates, decoder: decoder)
try self._parseSyncUpdates(updates)
} catch {
StoreCenter.main.log(message: error.localizedDescription)
Logger.error(error)
@ -387,7 +405,7 @@ public class StoreCenter {
if let deletions = json["deletions"] as? [String: Any] {
do {
try self._parseSyncDeletions(deletions, decoder: decoder)
try self._parseSyncDeletions(deletions)
} catch {
StoreCenter.main.log(message: error.localizedDescription)
Logger.error(error)
@ -406,21 +424,21 @@ public class StoreCenter {
}
}
fileprivate func _parseSyncUpdates(_ updates: [String: Any], decoder: JSONDecoder) throws {
fileprivate func _parseSyncUpdates(_ updates: [String: Any]) throws {
for (className, updateData) in updates {
guard let updateArray = updateData as? [[String: Any]] else {
Logger.w("Invalid update data for \(className)")
continue
}
let type = try self._classFromClassName(className)
let type = try StoreCenter.classFromName(className)
for updateItem in updateArray {
do {
let jsonData = try JSONSerialization.data(
withJSONObject: updateItem, options: [])
let decodedObject = try decoder.decode(type, from: jsonData)
let decodedObject = try JSON.decoder.decode(type, from: jsonData)
let storeId: String? = decodedObject.getStoreId()
StoreCenter.main.synchronizationAddOrUpdate(decodedObject, storeId: storeId)
@ -431,38 +449,35 @@ public class StoreCenter {
}
}
fileprivate func _parseSyncDeletions(_ deletions: [String: Any], decoder: JSONDecoder) throws {
fileprivate func _parseSyncDeletions(_ deletions: [String: Any]) throws {
for (className, updateDeletions) in deletions {
guard let deletionArray = updateDeletions as? [[String: Any]] else {
guard let deletedItem = updateDeletions as? [Any] else {
Logger.w("Invalid update data for \(className)")
continue
}
let type = try self._classFromClassName(className)
for updateItem in deletionArray {
if let object = updateItem["data"] {
do {
let jsonData = try JSONSerialization.data(
withJSONObject: object, options: [])
let decodedObject = try decoder.decode(type, from: jsonData)
let storeId = updateItem["storeId"] as? String
StoreCenter.main.synchronizationDelete(
instance: decodedObject, storeId: storeId)
} catch {
Logger.error(error)
}
for deleted in deletedItem {
do {
let data = try JSONSerialization.data(withJSONObject: deleted, options: [])
let deletedObject = try JSON.decoder.decode(DeletedObject.self, from: data)
StoreCenter.main.synchronizationDelete(id: deletedObject.modelId, model: className, storeId: deletedObject.storeId)
} catch {
Logger.error(error)
}
}
}
}
fileprivate func _classFromClassName(_ className: String) throws -> any SyncedStorable.Type {
static func classFromName(_ className: String) throws -> any SyncedStorable.Type {
let fullClassName = "PadelClub.\(className)"
let modelClass: AnyClass? = NSClassFromString(fullClassName)
guard let projectName = Bundle.main.infoDictionary?["CFBundleName"] as? String else {
throw LeStorageError.cantAccessCFBundleName
}
let modelClass: AnyClass? = NSClassFromString("\(projectName).\(className)")
if let type = modelClass as? any SyncedStorable.Type {
return type
} else {
@ -494,12 +509,26 @@ public class StoreCenter {
}
}
func synchronizationDelete<T: Storable>(instance: T, storeId: String?) {
func synchronizationDelete(id: String, model: String, storeId: String?) {
DispatchQueue.main.async {
self._store(id: storeId)?.deleteNoSync(instance: instance)
self._cleanupDataLog(dataId: instance.stringId)
do {
let type = try StoreCenter.classFromName(model)
try self._store(id: storeId)?.deleteNoSync(type: type, id: id)
} catch {
Logger.error(error)
}
self._cleanupDataLog(dataId: id)
}
}
// func synchronizationDelete<T: Storable>(instance: T, storeId: String?) {
// DispatchQueue.main.async {
// self._store(id: storeId)?.deleteNoSync(instance: instance)
// self._cleanupDataLog(dataId: instance.stringId)
// }
// }
fileprivate func _cleanupDataLog(dataId: String) {
let logs = self._dataLogs.filter { $0.dataId == dataId }
@ -712,3 +741,8 @@ public class StoreCenter {
}
}
class DeletedObject: Codable {
var modelId: String
var storeId: String?
}

@ -10,20 +10,16 @@ import Foundation
extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
/// Migrates if necessary and asynchronously decodes the json file
func load() {
func load() async {
do {
if self.inMemory {
Task {
try await self.loadDataFromServerIfAllowed()
}
try await self.loadDataFromServerIfAllowed()
} else {
try self.loadFromFile()
}
} catch {
Logger.error(error)
}
}
/// Loads the collection using the server data only if the collection file doesn't exists
@ -86,6 +82,33 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
self.deleteItem(instance)
}
/// Deletes the instance in the collection without synchronization
func deleteByStringIdNoSync(_ id: String) {
defer {
self.setChanged()
}
if let realId = self._buildRealId(id: id) {
if let instance = self.findById(realId) {
self.deleteItem(instance)
}
} else {
Logger.w("CRITICAL: collection \(T.resourceName()) could not build id from \(id)")
StoreCenter.main.log(message: "Could not build an id from \(id)")
}
}
fileprivate func _buildRealId(id: String) -> T.ID? {
switch T.ID.self {
case is String.Type:
return id as? T.ID
case is Int64.Type:
return Formatter.number.number(from: id)?.int64Value as? T.ID
default:
print("ID is neither String nor Int")
return nil
}
}
public func addOrUpdate(instance: T) {
defer {
self.setChanged()
@ -119,7 +142,7 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
}
public func delete(instance: T) throws {
public func delete(instance: T) {
defer {
self.setChanged()
}

@ -69,15 +69,11 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
}
}
/// Denotes a collection that loads and writes asynchronously
fileprivate var asynchronousIO: Bool = true
/// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false
init(store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false) {
init(store: Store, indexed: Bool = false, inMemory: Bool = false) {
// self.synchronized = synchronized
self.asynchronousIO = asynchronousIO
if indexed {
self._indexes = [:]
}
@ -121,17 +117,9 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Starts the JSON file decoding synchronously or asynchronously
func loadFromFile() throws {
if self.asynchronousIO {
Task(priority: .high) {
try self._decodeJSONFile()
}
} else {
try self._decodeJSONFile()
}
try self._decodeJSONFile()
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws {
@ -143,18 +131,9 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
for item in decoded {
item.store = self.store
}
if self.asynchronousIO {
DispatchQueue.main.async {
self._setItems(decoded)
self.setAsLoaded()
}
} else {
self._setItems(decoded)
self.setAsLoaded()
}
} else {
self.setAsLoaded()
self._setItems(decoded)
}
self.setAsLoaded()
}
/// Sets the collection as loaded
@ -211,8 +190,14 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self.addItem(instance: instance)
}
func deleteById(_ id: T.ID) {
if let instance = self.findById(id) {
self.delete(instance: instance)
}
}
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T) throws {
public func delete(instance: T) {
defer {
self._hasChanged = true
}
@ -231,17 +216,6 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
}
}
/// Deletes an instance in the collection. Also:
/// - Removes its reference from the index
/// - Notifies the server of the deletion
/// - Calls `hasBeenDeleted` on the deleted instance
// fileprivate func _delete(_ instance: T) throws {
// instance.deleteDependencies()
// self.items.removeAll { $0.id == instance.id }
// self._indexes?.removeValue(forKey: instance.id)
// instance.hasBeenDeleted()
// }
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
self.addSequence(sequence)
@ -293,7 +267,6 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id)
instance.hasBeenDeleted()
}
/// Returns the instance corresponding to the provided [id]
@ -305,12 +278,12 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
}
/// Deletes the instance corresponding to the provided [id]
public func deleteById(_ id: T.ID) throws {
if let instance = self.findById(id) {
self.deleteItem(instance)
// try self.delete(instance: instance)
}
}
// public func deleteById(_ id: T.ID) throws {
// if let instance = self.findById(id) {
// self.deleteItem(instance)
// self._hasChanged = true
// }
// }
/// Proceeds to "hard" delete the items without synchronizing them
/// Also removes related API calls
@ -323,7 +296,6 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index)
}
item.hasBeenDeleted()
// Task {
// do {
@ -355,11 +327,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
guard !self.inMemory else { return }
if self.asynchronousIO {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time
self._write()
}
} else {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time
self._write()
}
}

@ -22,6 +22,10 @@ public protocol SyncedStorable: Storable {
}
protocol URLParameterConvertible {
func queryParameters() -> [String : String]
}
public protocol SideStorable {
var storeId: String? { get set }
}

@ -7,22 +7,26 @@
import Foundation
fileprivate var jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
#if DEBUG
encoder.outputFormatting = .prettyPrinted
#endif
encoder.dateEncodingStrategy = .iso8601
return encoder
}()
class JSON {
static var encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
#if DEBUG
encoder.outputFormatting = .prettyPrinted
#endif
encoder.dateEncodingStrategy = .iso8601
return encoder
}()
fileprivate var jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
static var decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
}
extension Encodable {
@ -32,11 +36,11 @@ extension Encodable {
}
public func jsonData() throws -> Data {
return try jsonEncoder.encode(self)
return try JSON.encoder.encode(self)
}
public func prettyJSONString() throws -> String {
let data = try jsonEncoder.encode(self)
let data = try JSON.encoder.encode(self)
return String(data: data, encoding: .utf8) ?? ""
}
@ -57,11 +61,11 @@ extension String {
extension Data {
public func decode<T : Decodable>() throws -> T {
return try jsonDecoder.decode(T.self, from: self)
return try JSON.decoder.decode(T.self, from: self)
}
public func decodeArray<T : Decodable>() throws -> [T] {
return try jsonDecoder.decode([T].self, from: self)
return try JSON.decoder.decode([T].self, from: self)
}
}

@ -26,7 +26,7 @@ public enum ServiceError: Error {
case missingUserName
case missingUserId
case responseError(response: String)
case cantDecodeData(content: String?)
case cantDecodeData(resource: String, method: String, content: String?)
}
public enum UUIDError: Error {
@ -35,4 +35,5 @@ public enum UUIDError: Error {
public enum LeStorageError: Error {
case cantFindClassFromName(name: String)
case cantAccessCFBundleName
}

@ -0,0 +1,12 @@
//
// Formatter.swift
// LeStorage
//
// Created by Laurent Morvillier on 30/10/2024.
//
class Formatter {
static let number: NumberFormatter = NumberFormatter()
}
Loading…
Cancel
Save