diff --git a/Sources/ParseSwift/Extensions/URLSession.swift b/Sources/ParseSwift/Extensions/URLSession.swift index 60ff25e92..204a578c6 100644 --- a/Sources/ParseSwift/Extensions/URLSession.swift +++ b/Sources/ParseSwift/Extensions/URLSession.swift @@ -66,11 +66,17 @@ internal extension URLSession { responseError: Error?, mapper: @escaping (Data) throws -> U) -> Result { if let responseError = responseError { - guard let parseError = responseError as? ParseError else { - return .failure(ParseError(code: .unknownError, - message: "Unable to connect with parse-server: \(responseError)")) + if let urlError = responseError as? URLError, + urlError.code == URLError.Code.notConnectedToInternet || urlError.code == URLError.Code.dataNotAllowed { + return .failure(ParseError(code: .notConnectedToInternet, + message: "Unable to connect with the internet: \(responseError)")) + } else { + guard let parseError = responseError as? ParseError else { + return .failure(ParseError(code: .unknownError, + message: "Unable to connect with parse-server: \(responseError)")) + } + return .failure(parseError) } - return .failure(parseError) } guard let response = urlResponse else { guard let parseError = responseError as? ParseError else { diff --git a/Sources/ParseSwift/Objects/ParseObject+async.swift b/Sources/ParseSwift/Objects/ParseObject+async.swift index de7f30c4b..3c487f385 100644 --- a/Sources/ParseSwift/Objects/ParseObject+async.swift +++ b/Sources/ParseSwift/Objects/ParseObject+async.swift @@ -42,9 +42,11 @@ public extension ParseObject { - throws: An error of type `ParseError`. */ @discardableResult func save(ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options = []) async throws -> Self { try await withCheckedThrowingContinuation { continuation in self.save(ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + ignoringLocalStore: ignoringLocalStore, options: options, completion: continuation.resume) } @@ -56,9 +58,11 @@ public extension ParseObject { - returns: Returns the saved `ParseObject`. - throws: An error of type `ParseError`. */ - @discardableResult func create(options: API.Options = []) async throws -> Self { + @discardableResult func create(ignoringLocalStore: Bool = false, + options: API.Options = []) async throws -> Self { try await withCheckedThrowingContinuation { continuation in - self.create(options: options, + self.create(ignoringLocalStore: ignoringLocalStore, + options: options, completion: continuation.resume) } } @@ -69,9 +73,11 @@ public extension ParseObject { - returns: Returns the saved `ParseObject`. - throws: An error of type `ParseError`. */ - @discardableResult func replace(options: API.Options = []) async throws -> Self { + @discardableResult func replace(ignoringLocalStore: Bool = false, + options: API.Options = []) async throws -> Self { try await withCheckedThrowingContinuation { continuation in - self.replace(options: options, + self.replace(ignoringLocalStore: ignoringLocalStore, + options: options, completion: continuation.resume) } } @@ -81,10 +87,12 @@ public extension ParseObject { - parameter options: A set of header options sent to the server. Defaults to an empty set. - returns: Returns the saved `ParseObject`. - throws: An error of type `ParseError`. - */ - @discardableResult internal func update(options: API.Options = []) async throws -> Self { + */ + @discardableResult internal func update(ignoringLocalStore: Bool = false, + options: API.Options = []) async throws -> Self { try await withCheckedThrowingContinuation { continuation in - self.update(options: options, + self.update(ignoringLocalStore: ignoringLocalStore, + options: options, completion: continuation.resume) } } @@ -159,11 +167,13 @@ public extension Sequence where Element: ParseObject { @discardableResult func saveAll(batchLimit limit: Int? = nil, transaction: Bool = configuration.isUsingTransactions, ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options = []) async throws -> [(Result)] { try await withCheckedThrowingContinuation { continuation in self.saveAll(batchLimit: limit, transaction: transaction, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + ignoringLocalStore: ignoringLocalStore, options: options, completion: continuation.resume) } @@ -188,10 +198,12 @@ public extension Sequence where Element: ParseObject { */ @discardableResult func createAll(batchLimit limit: Int? = nil, transaction: Bool = configuration.isUsingTransactions, + ignoringLocalStore: Bool = false, options: API.Options = []) async throws -> [(Result)] { try await withCheckedThrowingContinuation { continuation in self.createAll(batchLimit: limit, transaction: transaction, + ignoringLocalStore: ignoringLocalStore, options: options, completion: continuation.resume) } @@ -216,10 +228,12 @@ public extension Sequence where Element: ParseObject { */ @discardableResult func replaceAll(batchLimit limit: Int? = nil, transaction: Bool = configuration.isUsingTransactions, + ignoringLocalStore: Bool = false, options: API.Options = []) async throws -> [(Result)] { try await withCheckedThrowingContinuation { continuation in self.replaceAll(batchLimit: limit, transaction: transaction, + ignoringLocalStore: ignoringLocalStore, options: options, completion: continuation.resume) } @@ -244,10 +258,12 @@ public extension Sequence where Element: ParseObject { */ internal func updateAll(batchLimit limit: Int? = nil, transaction: Bool = configuration.isUsingTransactions, + ignoringLocalStore: Bool = false, options: API.Options = []) async throws -> [(Result)] { try await withCheckedThrowingContinuation { continuation in self.updateAll(batchLimit: limit, transaction: transaction, + ignoringLocalStore: ignoringLocalStore, options: options, completion: continuation.resume) } @@ -363,6 +379,7 @@ or disable transactions for this call. func command(method: Method, ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options, callbackQueue: DispatchQueue) async throws -> Self { let (savedChildObjects, savedChildFiles) = try await self.ensureDeepSave(options: options) @@ -378,15 +395,23 @@ or disable transactions for this call. case .update: command = try self.updateCommand() } - return try await command + let commandResult = try await command .executeAsync(options: options, callbackQueue: callbackQueue, childObjects: savedChildObjects, childFiles: savedChildFiles) + if !ignoringLocalStore { + try? saveLocally(method: method) + } + return commandResult } catch { let defaultError = ParseError(code: .unknownError, message: error.localizedDescription) let parseError = error as? ParseError ?? defaultError + + if !ignoringLocalStore { + try? saveLocally(method: method, error: parseError) + } throw parseError } } @@ -398,6 +423,7 @@ internal extension Sequence where Element: ParseObject { batchLimit limit: Int?, transaction: Bool, ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options, callbackQueue: DispatchQueue) async throws -> [(Result)] { var options = options @@ -458,11 +484,19 @@ internal extension Sequence where Element: ParseObject { childFiles: childFiles) returnBatch.append(contentsOf: saved) } + + if !ignoringLocalStore { + try? saveLocally(method: method) + } return returnBatch } catch { let defaultError = ParseError(code: .unknownError, message: error.localizedDescription) let parseError = error as? ParseError ?? defaultError + + if !ignoringLocalStore { + try? saveLocally(method: method, error: parseError) + } throw parseError } } diff --git a/Sources/ParseSwift/Objects/ParseObject.swift b/Sources/ParseSwift/Objects/ParseObject.swift index 84db2f227..9a0c32cde 100644 --- a/Sources/ParseSwift/Objects/ParseObject.swift +++ b/Sources/ParseSwift/Objects/ParseObject.swift @@ -478,6 +478,8 @@ transactions for this call. - parameter ignoringCustomObjectIdConfig: Ignore checking for `objectId` when `ParseConfiguration.isRequiringCustomObjectIds = true` to allow for mixed `objectId` environments. Defaults to false. + - parameter ignoringLocalStore: Ignore storing objects to the local store. Mainly used for internal use + since default use should be set via policy on initialize. Defaults to false. - parameter options: A set of header options sent to the server. Defaults to an empty set. - parameter callbackQueue: The queue to return to after completion. Default value of .main. - parameter completion: The block to execute. @@ -501,6 +503,7 @@ transactions for this call. batchLimit limit: Int? = nil, transaction: Bool = configuration.isUsingTransactions, ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options = [], callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void @@ -513,6 +516,7 @@ transactions for this call. batchLimit: limit, transaction: transaction, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(objects)) @@ -530,6 +534,7 @@ transactions for this call. batchLimit: limit, transaction: transaction, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -543,6 +548,8 @@ transactions for this call. Defaults to 50. - parameter transaction: Treat as an all-or-nothing operation. If some operation failure occurs that prevents the transaction from completing, then none of the objects are committed to the Parse Server database. + - parameter ignoringLocalStore: Ignore storing objects to the local store. Mainly used for internal use + since default use should be set via policy on initialize. Defaults to false. - parameter options: A set of header options sent to the server. Defaults to an empty set. - parameter callbackQueue: The queue to return to after completion. Default value of .main. - parameter completion: The block to execute. @@ -556,6 +563,7 @@ transactions for this call. func createAll( // swiftlint:disable:this function_body_length cyclomatic_complexity batchLimit limit: Int? = nil, transaction: Bool = configuration.isUsingTransactions, + ignoringLocalStore: Bool = false, options: API.Options = [], callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void @@ -567,6 +575,7 @@ transactions for this call. let objects = try await batchCommand(method: method, batchLimit: limit, transaction: transaction, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(objects)) @@ -583,6 +592,7 @@ transactions for this call. batchCommand(method: method, batchLimit: limit, transaction: transaction, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -596,6 +606,8 @@ transactions for this call. Defaults to 50. - parameter transaction: Treat as an all-or-nothing operation. If some operation failure occurs that prevents the transaction from completing, then none of the objects are committed to the Parse Server database. + - parameter ignoringLocalStore: Ignore storing objects to the local store. Mainly used for internal use + since default use should be set via policy on initialize. Defaults to false. - parameter options: A set of header options sent to the server. Defaults to an empty set. - parameter callbackQueue: The queue to return to after completion. Default value of .main. - parameter completion: The block to execute. @@ -609,6 +621,7 @@ transactions for this call. func replaceAll( // swiftlint:disable:this function_body_length cyclomatic_complexity batchLimit limit: Int? = nil, transaction: Bool = configuration.isUsingTransactions, + ignoringLocalStore: Bool = false, options: API.Options = [], callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void @@ -620,6 +633,7 @@ transactions for this call. let objects = try await batchCommand(method: method, batchLimit: limit, transaction: transaction, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(objects)) @@ -636,6 +650,7 @@ transactions for this call. batchCommand(method: method, batchLimit: limit, transaction: transaction, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -649,6 +664,8 @@ transactions for this call. Defaults to 50. - parameter transaction: Treat as an all-or-nothing operation. If some operation failure occurs that prevents the transaction from completing, then none of the objects are committed to the Parse Server database. + - parameter ignoringLocalStore: Ignore storing objects to the local store. Mainly used for internal use + since default use should be set via policy on initialize. Defaults to false. - parameter options: A set of header options sent to the server. Defaults to an empty set. - parameter callbackQueue: The queue to return to after completion. Default value of .main. - parameter completion: The block to execute. @@ -662,6 +679,7 @@ transactions for this call. internal func updateAll( // swiftlint:disable:this function_body_length cyclomatic_complexity batchLimit limit: Int? = nil, transaction: Bool = configuration.isUsingTransactions, + ignoringLocalStore: Bool = false, options: API.Options = [], callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void @@ -673,6 +691,7 @@ transactions for this call. let objects = try await batchCommand(method: method, batchLimit: limit, transaction: transaction, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(objects)) @@ -689,6 +708,7 @@ transactions for this call. batchCommand(method: method, batchLimit: limit, transaction: transaction, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -699,6 +719,7 @@ transactions for this call. batchLimit limit: Int?, transaction: Bool, ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options, callbackQueue: DispatchQueue, completion: @escaping (Result<[(Result)], ParseError>) -> Void) { @@ -800,10 +821,16 @@ transactions for this call. case .success(let saved): returnBatch.append(contentsOf: saved) if completed == (batches.count - 1) { + if !ignoringLocalStore { + try? saveLocally(method: method) + } completion(.success(returnBatch)) } completed += 1 case .failure(let error): + if !ignoringLocalStore { + try? saveLocally(method: method, error: error) + } completion(.failure(error)) return } @@ -1185,6 +1212,8 @@ extension ParseObject { - parameter ignoringCustomObjectIdConfig: Ignore checking for `objectId` when `ParseConfiguration.isRequiringCustomObjectIds = true` to allow for mixed `objectId` environments. Defaults to false. + - parameter ignoringLocalStore: Ignore storing objects to the local store. Mainly used for internal use + since default use should be set via policy on initialize. Defaults to false. - parameter options: A set of header options sent to the server. Defaults to an empty set. - parameter callbackQueue: The queue to return to after completion. Default value of .main. - parameter completion: The block to execute. @@ -1201,6 +1230,7 @@ extension ParseObject { */ public func save( ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options = [], callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void @@ -1211,8 +1241,10 @@ extension ParseObject { do { let object = try await command(method: method, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) + completion(.success(object)) } catch { let defaultError = ParseError(code: .unknownError, @@ -1226,6 +1258,7 @@ extension ParseObject { #else command(method: method, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -1234,13 +1267,16 @@ extension ParseObject { /** Creates the `ParseObject` *asynchronously* and executes the given callback block. - + + - parameter ignoringLocalStore: Ignore storing objects to the local store. Mainly used for internal use + since default use should be set via policy on initialize. Defaults to false. - parameter options: A set of header options sent to the server. Defaults to an empty set. - parameter callbackQueue: The queue to return to after completion. Default value of .main. - parameter completion: The block to execute. It should have the following argument signature: `(Result)`. */ public func create( + ignoringLocalStore: Bool = false, options: API.Options = [], callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void @@ -1250,6 +1286,7 @@ extension ParseObject { Task { do { let object = try await command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(object)) @@ -1264,6 +1301,7 @@ extension ParseObject { } #else command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -1272,13 +1310,16 @@ extension ParseObject { /** Replaces the `ParseObject` *asynchronously* and executes the given callback block. - + + - parameter ignoringLocalStore: Ignore storing objects to the local store. Mainly used for internal use + since default use should be set via policy on initialize. Defaults to false. - parameter options: A set of header options sent to the server. Defaults to an empty set. - parameter callbackQueue: The queue to return to after completion. Default value of .main. - parameter completion: The block to execute. It should have the following argument signature: `(Result)`. */ public func replace( + ignoringLocalStore: Bool = false, options: API.Options = [], callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void @@ -1288,6 +1329,7 @@ extension ParseObject { Task { do { let object = try await command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(object)) @@ -1302,6 +1344,7 @@ extension ParseObject { } #else command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -1310,13 +1353,16 @@ extension ParseObject { /** Updates the `ParseObject` *asynchronously* and executes the given callback block. - + + - parameter ignoringLocalStore: Ignore storing objects to the local store. Mainly used for internal use + since default use should be set via policy on initialize. Defaults to false. - parameter options: A set of header options sent to the server. Defaults to an empty set. - parameter callbackQueue: The queue to return to after completion. Default value of .main. - parameter completion: The block to execute. It should have the following argument signature: `(Result)`. */ func update( + ignoringLocalStore: Bool = false, options: API.Options = [], callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void @@ -1326,6 +1372,7 @@ extension ParseObject { Task { do { let object = try await command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(object)) @@ -1340,6 +1387,7 @@ extension ParseObject { } #else command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -1348,6 +1396,7 @@ extension ParseObject { func command(method: Method, ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options, callbackQueue: DispatchQueue, completion: @escaping (Result) -> Void) { @@ -1371,16 +1420,28 @@ extension ParseObject { childObjects: savedChildObjects, childFiles: savedChildFiles, completion: completion) + + if !ignoringLocalStore { + try? saveLocally(method: method) + } } catch { let defaultError = ParseError(code: .unknownError, message: error.localizedDescription) let parseError = error as? ParseError ?? defaultError + + if !ignoringLocalStore { + try? saveLocally(method: method, error: parseError) + } callbackQueue.async { completion(.failure(parseError)) } } return } + + if !ignoringLocalStore { + try? saveLocally(method: method, error: parseError) + } callbackQueue.async { completion(.failure(parseError)) } diff --git a/Sources/ParseSwift/Parse.swift b/Sources/ParseSwift/Parse.swift index cdf961312..555361798 100644 --- a/Sources/ParseSwift/Parse.swift +++ b/Sources/ParseSwift/Parse.swift @@ -17,6 +17,7 @@ internal func initialize(applicationId: String, masterKey: String? = nil, serverURL: URL, liveQueryServerURL: URL? = nil, + offlinePolicy: ParseConfiguration.OfflinePolicy = .disabled, requiringCustomObjectIds: Bool = false, usingTransactions: Bool = false, usingEqualQueryConstraint: Bool = false, @@ -39,6 +40,7 @@ internal func initialize(applicationId: String, masterKey: masterKey, serverURL: serverURL, liveQueryServerURL: liveQueryServerURL, + offlinePolicy: offlinePolicy, requiringCustomObjectIds: requiringCustomObjectIds, usingTransactions: usingTransactions, usingEqualQueryConstraint: usingEqualQueryConstraint, @@ -226,6 +228,7 @@ public func initialize( masterKey: String? = nil, serverURL: URL, liveQueryServerURL: URL? = nil, + offlinePolicy: ParseConfiguration.OfflinePolicy = .disabled, requiringCustomObjectIds: Bool = false, usingTransactions: Bool = false, usingEqualQueryConstraint: Bool = false, @@ -248,6 +251,7 @@ public func initialize( masterKey: masterKey, serverURL: serverURL, liveQueryServerURL: liveQueryServerURL, + offlinePolicy: offlinePolicy, requiringCustomObjectIds: requiringCustomObjectIds, usingTransactions: usingTransactions, usingEqualQueryConstraint: usingEqualQueryConstraint, diff --git a/Sources/ParseSwift/ParseConstants.swift b/Sources/ParseSwift/ParseConstants.swift index 8f54a5856..15b56842c 100644 --- a/Sources/ParseSwift/ParseConstants.swift +++ b/Sources/ParseSwift/ParseConstants.swift @@ -15,6 +15,9 @@ enum ParseConstants { static let fileManagementPrivateDocumentsDirectory = "Private Documents/" static let fileManagementLibraryDirectory = "Library/" static let fileDownloadsDirectory = "Downloads" + static let fileObjectsDirectory = "Objects" + static let fetchObjectsFile = "FetchObjects" + static let queryObjectsFile = "QueryObjects" static let bundlePrefix = "com.parse.ParseSwift" static let batchLimit = 50 static let includeAllKey = "*" @@ -35,7 +38,7 @@ enum ParseConstants { #endif } -enum Method: String { +enum Method: String, Codable { case save, create, replace, update } diff --git a/Sources/ParseSwift/Storage/LocalStorage.swift b/Sources/ParseSwift/Storage/LocalStorage.swift new file mode 100644 index 000000000..12a49d5df --- /dev/null +++ b/Sources/ParseSwift/Storage/LocalStorage.swift @@ -0,0 +1,520 @@ +// +// LocalStorage.swift +// +// +// Created by Damian Van de Kauter on 03/12/2022. +// + +import Foundation + +public extension ParseObject { + + /** + Fetch all local objects. + + - returns: If objects are more recent on the database, it will replace the local objects and return them. + + - note: You will need to run this on every `ParseObject` that needs to fetch it's local objects + after creating offline objects. + */ + @discardableResult static func fetchLocalStore(_ type: T.Type) async throws -> [T]? { + return try await LocalStorage.fetchLocalObjects(type) + } +} + +internal struct LocalStorage { + static let fileManager = FileManager.default + + static func save(_ object: T, + queryIdentifier: String?) throws { + let objectData = try ParseCoding.jsonEncoder().encode(object) + + guard let objectId = object.objectId else { + throw ParseError(code: .missingObjectId, message: "Object has no valid objectId") + } + + let objectsDirectoryPath = try ParseFileManager.objectsDirectory(className: object.className) + let objectPath = objectsDirectoryPath.appendingPathComponent(objectId) + + if fileManager.fileExists(atPath: objectPath.path) { + try objectData.write(to: objectPath) + } else { + fileManager.createFile(atPath: objectPath.path, contents: objectData, attributes: nil) + } + + if let queryIdentifier = queryIdentifier { + try self.saveQueryObjects([object], queryIdentifier: queryIdentifier) + } + } + + static func saveAll(_ objects: [T], + queryIdentifier: String?) throws { + var successObjects: [T] = [] + for object in objects { + let objectData = try ParseCoding.jsonEncoder().encode(object) + guard let objectId = object.objectId else { + throw ParseError(code: .missingObjectId, message: "Object has no valid objectId") + } + + let objectsDirectoryPath = try ParseFileManager.objectsDirectory(className: object.className) + let objectPath = objectsDirectoryPath.appendingPathComponent(objectId) + + if fileManager.fileExists(atPath: objectPath.path) { + try objectData.write(to: objectPath) + } else { + fileManager.createFile(atPath: objectPath.path, contents: objectData, attributes: nil) + } + + successObjects.append(object) + } + + if let queryIdentifier = queryIdentifier { + try self.saveQueryObjects(successObjects, queryIdentifier: queryIdentifier) + } + } + + static func get(_ type: U.Type, + queryIdentifier: String) throws -> U? { + guard let queryObjects = try getQueryObjects()[queryIdentifier], + let queryObject = queryObjects.first else { return nil } + + let objectsDirectoryPath = try ParseFileManager.objectsDirectory(className: queryObject.className) + let objectPath = objectsDirectoryPath.appendingPathComponent(queryObject.objectId) + + let objectData = try Data(contentsOf: objectPath) + + return try ParseCoding.jsonDecoder().decode(U.self, from: objectData) + } + + static func getAll(_ type: U.Type, + queryIdentifier: String) throws -> [U]? { + guard let queryObjects = try getQueryObjects()[queryIdentifier] else { return nil } + + var allObjects: [U] = [] + for queryObject in queryObjects { + let objectsDirectoryPath = try ParseFileManager.objectsDirectory(className: queryObject.className) + let objectPath = objectsDirectoryPath.appendingPathComponent(queryObject.objectId) + + let objectData = try Data(contentsOf: objectPath) + if let object = try? ParseCoding.jsonDecoder().decode(U.self, from: objectData) { + allObjects.append(object) + } + } + + return (allObjects.isEmpty ? nil : allObjects) + } + + static fileprivate func saveFetchObjects(_ objects: [T], + method: Method) throws { + var fetchObjects = try getFetchObjects() + fetchObjects.append(contentsOf: try objects.map({ try FetchObject($0, method: method) })) + fetchObjects = fetchObjects.uniqueObjectsById + + try self.writeFetchObjects(fetchObjects) + } + + static fileprivate func removeFetchObjects(_ objects: [T]) throws { + var fetchObjects = try getFetchObjects() + let objectIds = objects.compactMap({ $0.objectId }) + fetchObjects.removeAll(where: { removableObject in + objectIds.contains(where: { currentObjectId in + removableObject.objectId == currentObjectId + }) + }) + fetchObjects = fetchObjects.uniqueObjectsById + + try self.writeFetchObjects(fetchObjects) + } + + static fileprivate func getFetchObjects() throws -> [FetchObject] { + let objectsDirectoryPath = try ParseFileManager.objectsDirectory() + let fetchObjectsPath = objectsDirectoryPath.appendingPathComponent(ParseConstants.fetchObjectsFile.hiddenFile) + + if fileManager.fileExists(atPath: fetchObjectsPath.path) { + let jsonData = try Data(contentsOf: fetchObjectsPath) + do { + return try ParseCoding.jsonDecoder().decode([FetchObject].self, from: jsonData).uniqueObjectsById + } catch { + try fileManager.removeItem(at: fetchObjectsPath) + return [] + } + } else { + return [] + } + } + + static private func writeFetchObjects(_ fetchObjects: [FetchObject]) throws { + let objectsDirectoryPath = try ParseFileManager.objectsDirectory() + let fetchObjectsPath = objectsDirectoryPath.appendingPathComponent(ParseConstants.fetchObjectsFile.hiddenFile) + + if fetchObjects.isEmpty { + try? fileManager.removeItem(at: fetchObjectsPath) + } else { + let jsonData = try ParseCoding.jsonEncoder().encode(fetchObjects) + + if fileManager.fileExists(atPath: fetchObjectsPath.path) { + try jsonData.write(to: fetchObjectsPath) + } else { + fileManager.createFile(atPath: fetchObjectsPath.path, contents: jsonData, attributes: nil) + } + } + } + + static fileprivate func saveQueryObjects(_ objects: [T], + queryIdentifier: String) throws { + var queryObjects = try getQueryObjects() + queryObjects[queryIdentifier] = try objects.map({ try QueryObject($0) }) + + try self.writeQueryObjects(queryObjects) + } + + static fileprivate func getQueryObjects() throws -> [String : [QueryObject]] { + let objectsDirectoryPath = try ParseFileManager.objectsDirectory() + let queryObjectsPath = objectsDirectoryPath.appendingPathComponent(ParseConstants.queryObjectsFile.hiddenFile) + + if fileManager.fileExists(atPath: queryObjectsPath.path) { + let jsonData = try Data(contentsOf: queryObjectsPath) + do { + return try ParseCoding.jsonDecoder().decode([String : [QueryObject]].self, from: jsonData) + } catch { + try fileManager.removeItem(at: queryObjectsPath) + return [:] + } + } else { + return [:] + } + } + + static private func writeQueryObjects(_ queryObjects: [String : [QueryObject]]) throws { + let objectsDirectoryPath = try ParseFileManager.objectsDirectory() + let queryObjectsPath = objectsDirectoryPath.appendingPathComponent(ParseConstants.queryObjectsFile.hiddenFile) + + if queryObjects.isEmpty { + try? fileManager.removeItem(at: queryObjectsPath) + } else { + let jsonData = try ParseCoding.jsonEncoder().encode(queryObjects) + + if fileManager.fileExists(atPath: queryObjectsPath.path) { + try jsonData.write(to: queryObjectsPath) + } else { + fileManager.createFile(atPath: queryObjectsPath.path, contents: jsonData, attributes: nil) + } + } + } + + /** + Fetch all local objects. + + - returns: If objects are more recent on the database, it will replace the local objects and return them. + */ + @discardableResult static func fetchLocalObjects(_ type: T.Type) async throws -> [T]? { + let fetchObjects = try getFetchObjects() + if fetchObjects.isEmpty { + return nil + } + + var saveObjects = try fetchObjects + .filter({ $0.method == .save }) + .asParseObjects(type) + var createObjects = try fetchObjects + .filter({ $0.method == .create }) + .asParseObjects(type) + var replaceObjects = try fetchObjects + .filter({ $0.method == .replace }) + .asParseObjects(type) + var updateObjects = try fetchObjects + .filter({ $0.method == .update }) + .asParseObjects(type) + + var cloudObjects: [T] = [] + + if Parse.configuration.offlinePolicy.enabled { + try await self.fetchLocalStore(.save, objects: &saveObjects, cloudObjects: &cloudObjects) + } + + if Parse.configuration.offlinePolicy.canCreate { + if Parse.configuration.isRequiringCustomObjectIds { + try await self.fetchLocalStore(.create, objects: &createObjects, cloudObjects: &cloudObjects) + } else { + assertionFailure("Enable custom objectIds") + } + } + + if Parse.configuration.offlinePolicy.enabled { + try await self.fetchLocalStore(.replace, objects: &replaceObjects, cloudObjects: &cloudObjects) + } + + if Parse.configuration.offlinePolicy.enabled { + try await self.fetchLocalStore(.update, objects: &updateObjects, cloudObjects: &cloudObjects) + } + + if cloudObjects.isEmpty { + return nil + } else { + try self.saveAll(cloudObjects, queryIdentifier: nil) + return cloudObjects + } + } + + private static func fetchLocalStore(_ method: Method, objects: inout [T], cloudObjects: inout [T]) async throws { + let queryObjects = T.query() + .where(containedIn(key: "objectId", array: objects.map({ $0.objectId }))) + .useLocalStore(false) + let foundObjects = try? await queryObjects.find() + + for object in objects { + if let matchingObject = foundObjects?.first(where: { $0.objectId == object.objectId }) { + if let objectUpdatedAt = object.updatedAt { + if let matchingObjectUpdatedAt = matchingObject.updatedAt { + if objectUpdatedAt < matchingObjectUpdatedAt { + objects.removeAll(where: { $0.objectId == matchingObject.objectId }) + cloudObjects.append(matchingObject) + } + } + } else { + objects.removeAll(where: { $0.objectId == matchingObject.objectId }) + cloudObjects.append(matchingObject) + } + } + } + + switch method { + case .save: + try await objects.saveAll(ignoringLocalStore: true) + case .create: + try await objects.createAll(ignoringLocalStore: true) + case .replace: + try await objects.replaceAll(ignoringLocalStore: true) + case .update: + _ = try await objects.updateAll(ignoringLocalStore: true) + } + + try self.removeFetchObjects(objects) + } +} + +internal struct FetchObject: Codable { + let objectId: String + let className: String + let updatedAt: Date + let method: Method + + init(_ object : T, method: Method) throws { + guard let objectId = object.objectId else { + throw ParseError(code: .missingObjectId, message: "Object has no valid objectId") + } + self.objectId = objectId + self.className = object.className + self.updatedAt = object.updatedAt ?? Date() + self.method = method + } +} + +internal struct QueryObject: Codable { + let objectId: String + let className: String + let queryDate: Date + + init(_ object : T) throws { + guard let objectId = object.objectId else { + throw ParseError(code: .missingObjectId, message: "Object has no valid objectId") + } + self.objectId = objectId + self.className = object.className + self.queryDate = Date() + } +} + +internal extension ParseObject { + + func saveLocally(method: Method? = nil, + queryIdentifier: String? = nil, + error: ParseError? = nil) throws { + if let method = method { + switch method { + case .save: + if Parse.configuration.offlinePolicy.enabled { + if let error = error { + if error.hasNoInternetConnection { + try LocalStorage.save(self, queryIdentifier: queryIdentifier) + try LocalStorage.saveFetchObjects([self], method: method) + } + } else { + try LocalStorage.save(self, queryIdentifier: queryIdentifier) + } + } + case .create: + if Parse.configuration.offlinePolicy.canCreate { + if Parse.configuration.isRequiringCustomObjectIds { + if let error = error { + if error.hasNoInternetConnection { + try LocalStorage.save(self, queryIdentifier: queryIdentifier) + try LocalStorage.saveFetchObjects([self], method: method) + } + } else { + try LocalStorage.save(self, queryIdentifier: queryIdentifier) + } + } else { + assertionFailure("Enable custom objectIds") + } + } + case .replace: + if Parse.configuration.offlinePolicy.enabled { + if let error = error { + if error.hasNoInternetConnection { + try LocalStorage.save(self, queryIdentifier: queryIdentifier) + try LocalStorage.saveFetchObjects([self], method: method) + } + } else { + try LocalStorage.save(self, queryIdentifier: queryIdentifier) + } + } + case .update: + if Parse.configuration.offlinePolicy.enabled { + if let error = error { + if error.hasNoInternetConnection { + try LocalStorage.save(self, queryIdentifier: queryIdentifier) + try LocalStorage.saveFetchObjects([self], method: method) + } + } else { + try LocalStorage.save(self, queryIdentifier: queryIdentifier) + } + } + } + } else { + if Parse.configuration.offlinePolicy.enabled { + try LocalStorage.save(self, queryIdentifier: queryIdentifier) + } + } + } +} + +internal extension Sequence where Element: ParseObject { + + func saveLocally(method: Method? = nil, + queryIdentifier: String? = nil, + error: ParseError? = nil) throws { + let objects = map { $0 } + + if let method = method { + switch method { + case .save: + if Parse.configuration.offlinePolicy.enabled { + if let error = error { + if error.hasNoInternetConnection { + try LocalStorage.saveAll(objects, queryIdentifier: queryIdentifier) + try LocalStorage.saveFetchObjects(objects, method: method) + } + } else { + try LocalStorage.saveAll(objects, queryIdentifier: queryIdentifier) + } + } + case .create: + if Parse.configuration.offlinePolicy.canCreate { + if Parse.configuration.isRequiringCustomObjectIds { + if let error = error { + if error.hasNoInternetConnection { + try LocalStorage.saveAll(objects, queryIdentifier: queryIdentifier) + try LocalStorage.saveFetchObjects(objects, method: method) + } + } else { + try LocalStorage.saveAll(objects, queryIdentifier: queryIdentifier) + } + } else { + assertionFailure("Enable custom objectIds") + } + } + case .replace: + if Parse.configuration.offlinePolicy.enabled { + if let error = error { + if error.hasNoInternetConnection { + try LocalStorage.saveAll(objects, queryIdentifier: queryIdentifier) + try LocalStorage.saveFetchObjects(objects, method: method) + } + } else { + try LocalStorage.saveAll(objects, queryIdentifier: queryIdentifier) + } + } + case .update: + if Parse.configuration.offlinePolicy.enabled { + if let error = error { + if error.hasNoInternetConnection { + try LocalStorage.saveAll(objects, queryIdentifier: queryIdentifier) + try LocalStorage.saveFetchObjects(objects, method: method) + } + } else { + try LocalStorage.saveAll(objects, queryIdentifier: queryIdentifier) + } + } + } + } else { + if Parse.configuration.offlinePolicy.enabled { + try LocalStorage.saveAll(objects, queryIdentifier: queryIdentifier) + } + } + } +} + +fileprivate extension String { + + /** + Creates a hidden file + */ + var hiddenFile: Self { + return "." + self + } +} + +fileprivate extension Sequence where Element == FetchObject { + + /** + Returns a unique array of `FetchObject`'s where each element is the most recent version of itself. + */ + var uniqueObjectsById: [Element] { + let fetchObjects = map { $0 }.sorted(by: { $0.updatedAt > $1.updatedAt }) + + var uniqueObjects: [Element] = [] + for fetchObject in fetchObjects { + uniqueObjects.append(fetchObjects.first(where: { $0.objectId == fetchObject.objectId }) ?? fetchObject) + } + + return uniqueObjects.isEmpty ? fetchObjects : uniqueObjects + } + + func asParseObjects(_ type: T.Type) throws -> [T] { + let fileManager = FileManager.default + + let fetchObjectIds = map { $0 }.filter({ $0.className == T.className }).map({ $0.objectId }) + + let objectsDirectoryPath = try ParseFileManager.objectsDirectory(className: T.className) + let directoryObjectIds = try fileManager.contentsOfDirectory(atPath: objectsDirectoryPath.path) + + var objects: [T] = [] + + for directoryObjectId in directoryObjectIds { + if fetchObjectIds.contains(directoryObjectId) { + if #available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *) { + let contentPath = objectsDirectoryPath.appending(component: directoryObjectId, directoryHint: .notDirectory) + + if fileManager.fileExists(atPath: contentPath.path) { + let jsonData = try Data(contentsOf: contentPath) + let object = try ParseCoding.jsonDecoder().decode(T.self, from: jsonData) + + objects.append(object) + } + } else { + let contentPath = objectsDirectoryPath.appendingPathComponent(directoryObjectId, isDirectory: false) + + if fileManager.fileExists(atPath: contentPath.path) { + let jsonData = try Data(contentsOf: contentPath) + let object = try ParseCoding.jsonDecoder().decode(T.self, from: jsonData) + + objects.append(object) + } + } + } + } + + return objects + } +} diff --git a/Sources/ParseSwift/Storage/ParseFileManager.swift b/Sources/ParseSwift/Storage/ParseFileManager.swift index bde16c2ee..b8b3fab85 100644 --- a/Sources/ParseSwift/Storage/ParseFileManager.swift +++ b/Sources/ParseSwift/Storage/ParseFileManager.swift @@ -227,6 +227,34 @@ public extension ParseFileManager { .appendingPathComponent(ParseConstants.fileDownloadsDirectory, isDirectory: true) } + + /** + The default directory for all `ParseObject`'s. + - parameter className: An optional value, that if set returns the objects directory for a specific class + - returns: The objects directory. + - throws: An error of type `ParseError`. + */ + static func objectsDirectory(className: String? = nil) throws -> URL { + guard let fileManager = ParseFileManager(), + let defaultDirectoryPath = fileManager.defaultDataDirectoryPath else { + throw ParseError(code: .unknownError, message: "Cannot create ParseFileManager") + } + let objectsDirectory = defaultDirectoryPath + .appendingPathComponent(ParseConstants.fileObjectsDirectory, + isDirectory: true) + try fileManager.createDirectoryIfNeeded(objectsDirectory.path) + + if let className = className { + let classDirectory = objectsDirectory + .appendingPathComponent(className, + isDirectory: true) + try fileManager.createDirectoryIfNeeded(classDirectory.path) + + return classDirectory + } else { + return objectsDirectory + } + } /** Check if a file exists in the Swift SDK download directory. diff --git a/Sources/ParseSwift/Types/ParseConfiguration.swift b/Sources/ParseSwift/Types/ParseConfiguration.swift index 1fdf8f92c..cba96f339 100644 --- a/Sources/ParseSwift/Types/ParseConfiguration.swift +++ b/Sources/ParseSwift/Types/ParseConfiguration.swift @@ -39,6 +39,9 @@ public struct ParseConfiguration { /// The live query server URL to connect to Parse Server. public internal(set) var liveQuerysServerURL: URL? + + /// Determines wheter or not objects need to be saved locally. + public internal(set) var offlinePolicy: OfflinePolicy /// Requires `objectId`'s to be created on the client. public internal(set) var isRequiringCustomObjectIds = false @@ -123,6 +126,7 @@ public struct ParseConfiguration { specified when using the SDK on a server. - parameter serverURL: The server URL to connect to Parse Server. - parameter liveQueryServerURL: The live query server URL to connect to Parse Server. + - parameter OfflinePolicy: When enabled, objects will be stored locally for offline usage. - parameter requiringCustomObjectIds: Requires `objectId`'s to be created on the client side for each object. Must be enabled on the server to work. - parameter usingTransactions: Use transactions when saving/updating multiple objects. @@ -166,6 +170,7 @@ public struct ParseConfiguration { webhookKey: String? = nil, serverURL: URL, liveQueryServerURL: URL? = nil, + offlinePolicy: OfflinePolicy = .disabled, requiringCustomObjectIds: Bool = false, usingTransactions: Bool = false, usingEqualQueryConstraint: Bool = false, @@ -187,6 +192,7 @@ public struct ParseConfiguration { self.masterKey = masterKey self.serverURL = serverURL self.liveQuerysServerURL = liveQueryServerURL + self.offlinePolicy = offlinePolicy self.isRequiringCustomObjectIds = requiringCustomObjectIds self.isUsingTransactions = usingTransactions self.isUsingEqualQueryConstraint = usingEqualQueryConstraint @@ -389,4 +395,34 @@ public struct ParseConfiguration { authentication: authentication) self.isMigratingFromObjcSDK = migratingFromObjcSDK } + + public enum OfflinePolicy { + + /** + When using the `create` Policy, you can get, create and save objects when offline. + - warning: Using this Policy requires you to enable `allowingCustomObjectIds`. + */ + case create + + /** + When using the `save` Policy, you can get and save objects when offline. + */ + case save + + /** + When using the `disabled` Policy, offline usage is disabled. + */ + case disabled + } +} + +extension ParseConfiguration.OfflinePolicy { + + var canCreate: Bool { + return self == .create + } + + var enabled: Bool { + return self == .create || self == .save + } } diff --git a/Sources/ParseSwift/Types/ParseError.swift b/Sources/ParseSwift/Types/ParseError.swift index 2667cde98..1ce4d4c9b 100644 --- a/Sources/ParseSwift/Types/ParseError.swift +++ b/Sources/ParseSwift/Types/ParseError.swift @@ -348,6 +348,11 @@ public struct ParseError: ParseTypeable, Swift.Error { a non-2XX status code. */ case xDomainRequest = 602 + + /** + Error code indicating that the device is not connected to the internet. + */ + case notConnectedToInternet = 1009 /** Error code indicating any other custom error sent from the Parse Server. @@ -558,3 +563,15 @@ public extension Error { containedIn(errorCodes) } } + +internal extension Error { + + /** + Validates if the given `ParseError` codes contains the error codes for no internet connection. + + - returns: A boolean indicating whether or not the `Error` is an internet connection error. + */ + var hasNoInternetConnection: Bool { + return self.equalsTo(.notConnectedToInternet) || self.equalsTo(.connectionFailed) + } +} diff --git a/Sources/ParseSwift/Types/Query.swift b/Sources/ParseSwift/Types/Query.swift index acb1e6c79..baec0e92e 100644 --- a/Sources/ParseSwift/Types/Query.swift +++ b/Sources/ParseSwift/Types/Query.swift @@ -20,6 +20,7 @@ public struct Query: ParseTypeable where T: ParseObject { internal var keys: Set? internal var include: Set? internal var order: [Order]? + internal var useLocalStore: Bool = false internal var isCount: Bool? internal var explain: Bool? internal var hint: AnyCodable? @@ -44,6 +45,40 @@ public struct Query: ParseTypeable where T: ParseObject { public var className: String { Self.className } + + internal var queryIdentifier: String { + var mutableQuery = self + mutableQuery.keys = nil + mutableQuery.include = nil + mutableQuery.excludeKeys = nil + mutableQuery.fields = nil + + guard let jsonData = try? ParseCoding.jsonEncoder().encode(mutableQuery), + let descriptionString = String(data: jsonData, encoding: .utf8) else { + return className + } + + //Sets need to be sorted to maintain the same queryIdentifier + let sortedKeys = (keys?.count == 0 ? [] : ["keys"]) + (keys?.sorted(by: { $0 < $1 }) ?? []) + let sortedInclude = (include?.count == 0 ? [] : ["include"]) + (include?.sorted(by: { $0 < $1 }) ?? []) + let sortedExcludeKeys = (excludeKeys?.count == 0 ? [] : ["excludeKeys"]) + (excludeKeys?.sorted(by: { $0 < $1 }) ?? []) + let sortedFieldsKeys = (fields?.count == 0 ? [] : ["fields"]) + (fields?.sorted(by: { $0 < $1 }) ?? []) + + let sortedSets = ( + sortedKeys + + sortedInclude + + sortedExcludeKeys + + sortedFieldsKeys + ).joined(separator: "") + + return ( + className + + sortedSets + + descriptionString + ).replacingOccurrences(of: "[^A-Za-z0-9]+", + with: "", + options: [.regularExpression]) + } struct AggregateBody: Codable where T: ParseObject { let pipeline: [[String: AnyCodable]]? @@ -435,6 +470,17 @@ public struct Query: ParseTypeable where T: ParseObject { mutableQuery.order = keys return mutableQuery } + + /** + Sort the results of the query based on the `Order` enum. + - parameter keys: An array of keys to order by. + - returns: The mutated instance of query for easy chaining. + */ + public func useLocalStore(_ state: Bool = true) -> Query { + var mutableQuery = self + mutableQuery.useLocalStore = state + return mutableQuery + } /** A variadic list of selected fields to receive updates on when the `Query` is used as a @@ -498,7 +544,23 @@ extension Query: Queryable { if limit == 0 { return [ResultType]() } - return try findCommand().execute(options: options) + if useLocalStore { + do { + let objects = try findCommand().execute(options: options) + try? objects.saveLocally(queryIdentifier: queryIdentifier) + + return objects + } catch let parseError { + if parseError.hasNoInternetConnection, + let localObjects = try? LocalStorage.getAll(ResultType.self, queryIdentifier: queryIdentifier) { + return localObjects + } else { + throw parseError + } + } + } else { + return try findCommand().execute(options: options) + } } /** @@ -548,7 +610,23 @@ extension Query: Queryable { do { try findCommand().executeAsync(options: options, callbackQueue: callbackQueue) { result in - completion(result) + if useLocalStore { + switch result { + case .success(let objects): + try? objects.saveLocally(queryIdentifier: queryIdentifier) + + completion(result) + case .failure(let failure): + if failure.hasNoInternetConnection, + let localObjects = try? LocalStorage.getAll(ResultType.self, queryIdentifier: queryIdentifier) { + completion(.success(localObjects)) + } else { + completion(.failure(failure)) + } + } + } else { + completion(result) + } } } catch { let parseError = ParseError(code: .unknownError, @@ -669,16 +747,30 @@ extension Query: Queryable { finished = true } } catch { - let defaultError = ParseError(code: .unknownError, - message: error.localizedDescription) - let parseError = error as? ParseError ?? defaultError - callbackQueue.async { - completion(.failure(parseError)) + if let urlError = error as? URLError, + urlError.code == URLError.Code.notConnectedToInternet || urlError.code == URLError.Code.dataNotAllowed, let localObjects = try? LocalStorage.getAll(ResultType.self, queryIdentifier: queryIdentifier) { + completion(.success(localObjects)) + } else { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + + if parseError.hasNoInternetConnection, + let localObjects = try? LocalStorage.getAll(ResultType.self, queryIdentifier: queryIdentifier) { + completion(.success(localObjects)) + } else { + callbackQueue.async { + completion(.failure(parseError)) + } + } } return } } - + + if useLocalStore { + try? results.saveLocally(queryIdentifier: queryIdentifier) + } callbackQueue.async { completion(.success(results)) } @@ -699,7 +791,23 @@ extension Query: Queryable { throw ParseError(code: .objectNotFound, message: "Object not found on the server.") } - return try firstCommand().execute(options: options) + if useLocalStore { + do { + let objects = try firstCommand().execute(options: options) + try? objects.saveLocally(queryIdentifier: queryIdentifier) + + return objects + } catch let parseError { + if parseError.hasNoInternetConnection, + let localObject = try? LocalStorage.get(ResultType.self, queryIdentifier: queryIdentifier) { + return localObject + } else { + throw parseError + } + } + } else { + return try firstCommand().execute(options: options) + } } /** @@ -755,7 +863,23 @@ extension Query: Queryable { do { try firstCommand().executeAsync(options: options, callbackQueue: callbackQueue) { result in - completion(result) + if useLocalStore { + switch result { + case .success(let object): + try? object.saveLocally(queryIdentifier: queryIdentifier) + + completion(result) + case .failure(let failure): + if failure.hasNoInternetConnection, + let localObject = try? LocalStorage.get(ResultType.self, queryIdentifier: queryIdentifier) { + completion(.success(localObject)) + } else { + completion(.failure(failure)) + } + } + } else { + completion(result) + } } } catch { let parseError = ParseError(code: .unknownError,