diff --git a/ParseSwift.playground/Pages/16 - Offline Mode.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/16 - Offline Mode.xcplaygroundpage/Contents.swift new file mode 100644 index 000000000..120149d23 --- /dev/null +++ b/ParseSwift.playground/Pages/16 - Offline Mode.xcplaygroundpage/Contents.swift @@ -0,0 +1,140 @@ +//: [Previous](@previous) + +//: For this page, make sure your build target is set to ParseSwift (macOS) and targeting +//: `My Mac` or whatever the name of your mac is. Also be sure your `Playground Settings` +//: in the `File Inspector` is `Platform = macOS`. This is because +//: Keychain in iOS Playgrounds behaves differently. Every page in Playgrounds should +//: be set to build for `macOS` unless specified. + +import PlaygroundSupport +import Foundation +import ParseSwift +PlaygroundPage.current.needsIndefiniteExecution = true + +//: In order to enable offline mode you need to set offlinePolicy to either `create` or `save` +//: `save` will allow you to save and fetch objects. +//: `create` will allow you to create, save and fetch objects. +//: Note that `create` will require you to enable customObjectIds. +ParseSwift.initialize(applicationId: "applicationId", + clientKey: "clientKey", + masterKey: "masterKey", + serverURL: URL(string: "http://localhost:1337/1")!, + offlinePolicy: .create, + requiringCustomObjectIds: true, + usingEqualQueryConstraint: false, + usingDataProtectionKeychain: false) + +struct GameScore: ParseObject { + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + + //: Your own properties. + var points: Int? + var timeStamp: Date? = Date() + var oldScore: Int? + var isHighest: Bool? + + /*: + Optional - implement your own version of merge + for faster decoding after updating your `ParseObject`. + */ + func merge(with object: Self) throws -> Self { + var updated = try mergeParse(with: object) + if updated.shouldRestoreKey(\.points, + original: object) { + updated.points = object.points + } + if updated.shouldRestoreKey(\.timeStamp, + original: object) { + updated.timeStamp = object.timeStamp + } + if updated.shouldRestoreKey(\.oldScore, + original: object) { + updated.oldScore = object.oldScore + } + if updated.shouldRestoreKey(\.isHighest, + original: object) { + updated.isHighest = object.isHighest + } + return updated + } +} + +var score = GameScore() +score.points = 200 +score.oldScore = 10 +score.isHighest = true +do { + try score.save() +} catch { + print(error) +} + +//: If you want to use local objects when an internet connection failed, +//: you need to set useLocalStore() +let afterDate = Date().addingTimeInterval(-300) +var query = GameScore.query("points" > 50, + "createdAt" > afterDate) + .useLocalStore() + .order([.descending("points")]) + +//: Query asynchronously (preferred way) - Performs work on background +//: queue and returns to specified callbackQueue. +//: If no callbackQueue is specified it returns to main queue. +query.limit(2) + .order([.descending("points")]) + .find(callbackQueue: .main) { results in + switch results { + case .success(let scores): + + assert(scores.count >= 1) + scores.forEach { score in + guard let createdAt = score.createdAt else { fatalError() } + assert(createdAt.timeIntervalSince1970 > afterDate.timeIntervalSince1970, "date should be ok") + print("Found score: \(score)") + } + + case .failure(let error): + if error.equalsTo(.objectNotFound) { + assertionFailure("Object not found for this query") + } else { + assertionFailure("Error querying: \(error)") + } + } +} + +//: Query synchronously (not preferred - all operations on current queue). +let results = try query.find() +assert(results.count >= 1) +results.forEach { score in + guard let createdAt = score.createdAt else { fatalError() } + assert(createdAt.timeIntervalSince1970 > afterDate.timeIntervalSince1970, "date should be ok") + print("Found score: \(score)") +} + +//: Query first asynchronously (preferred way) - Performs work on background +//: queue and returns to specified callbackQueue. +//: If no callbackQueue is specified it returns to main queue. +query.first { results in + switch results { + case .success(let score): + + guard score.objectId != nil, + let createdAt = score.createdAt else { fatalError() } + assert(createdAt.timeIntervalSince1970 > afterDate.timeIntervalSince1970, "date should be ok") + print("Found score: \(score)") + + case .failure(let error): + if error.containedIn([.objectNotFound, .invalidQuery]) { + assertionFailure("The query is invalid or the object is not found.") + } else { + assertionFailure("Error querying: \(error)") + } + } +} + +PlaygroundPage.current.finishExecution() +//: [Next](@next) diff --git a/ParseSwift.playground/Pages/16 - Analytics.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/17 - Analytics.xcplaygroundpage/Contents.swift similarity index 100% rename from ParseSwift.playground/Pages/16 - Analytics.xcplaygroundpage/Contents.swift rename to ParseSwift.playground/Pages/17 - Analytics.xcplaygroundpage/Contents.swift diff --git a/ParseSwift.playground/Pages/17 - SwiftUI - Finding Objects.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/18 - SwiftUI - Finding Objects.xcplaygroundpage/Contents.swift similarity index 100% rename from ParseSwift.playground/Pages/17 - SwiftUI - Finding Objects.xcplaygroundpage/Contents.swift rename to ParseSwift.playground/Pages/18 - SwiftUI - Finding Objects.xcplaygroundpage/Contents.swift diff --git a/ParseSwift.playground/Pages/18 - SwiftUI - Finding Objects With Custom ViewModel.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/19 - SwiftUI - Finding Objects With Custom ViewModel.xcplaygroundpage/Contents.swift similarity index 100% rename from ParseSwift.playground/Pages/18 - SwiftUI - Finding Objects With Custom ViewModel.xcplaygroundpage/Contents.swift rename to ParseSwift.playground/Pages/19 - SwiftUI - Finding Objects With Custom ViewModel.xcplaygroundpage/Contents.swift diff --git a/ParseSwift.playground/Pages/19 - SwiftUI - LiveQuery.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/20 - SwiftUI - LiveQuery.xcplaygroundpage/Contents.swift similarity index 100% rename from ParseSwift.playground/Pages/19 - SwiftUI - LiveQuery.xcplaygroundpage/Contents.swift rename to ParseSwift.playground/Pages/20 - SwiftUI - LiveQuery.xcplaygroundpage/Contents.swift diff --git a/ParseSwift.playground/Pages/20 - Cloud Schemas.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/21 - Cloud Schemas.xcplaygroundpage/Contents.swift similarity index 100% rename from ParseSwift.playground/Pages/20 - Cloud Schemas.xcplaygroundpage/Contents.swift rename to ParseSwift.playground/Pages/21 - Cloud Schemas.xcplaygroundpage/Contents.swift diff --git a/ParseSwift.playground/Pages/21 - Cloud Push Notifications.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/22 - Cloud Push Notifications.xcplaygroundpage/Contents.swift similarity index 100% rename from ParseSwift.playground/Pages/21 - Cloud Push Notifications.xcplaygroundpage/Contents.swift rename to ParseSwift.playground/Pages/22 - Cloud Push Notifications.xcplaygroundpage/Contents.swift diff --git a/ParseSwift.playground/Pages/22 - Cloud Hook Functions.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/23 - Cloud Hook Functions.xcplaygroundpage/Contents.swift similarity index 100% rename from ParseSwift.playground/Pages/22 - Cloud Hook Functions.xcplaygroundpage/Contents.swift rename to ParseSwift.playground/Pages/23 - Cloud Hook Functions.xcplaygroundpage/Contents.swift diff --git a/ParseSwift.playground/Pages/23 - Cloud Hook Triggers.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/24 - Cloud Hook Triggers.xcplaygroundpage/Contents.swift similarity index 100% rename from ParseSwift.playground/Pages/23 - Cloud Hook Triggers.xcplaygroundpage/Contents.swift rename to ParseSwift.playground/Pages/24 - Cloud Hook Triggers.xcplaygroundpage/Contents.swift diff --git a/ParseSwift.xcodeproj/project.pbxproj b/ParseSwift.xcodeproj/project.pbxproj index e1ad03430..6c2bee69c 100644 --- a/ParseSwift.xcodeproj/project.pbxproj +++ b/ParseSwift.xcodeproj/project.pbxproj @@ -973,6 +973,13 @@ 91F346C3269B88F7005727B6 /* ParseCloudViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F346C2269B88F7005727B6 /* ParseCloudViewModelTests.swift */; }; 91F346C4269B88F7005727B6 /* ParseCloudViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F346C2269B88F7005727B6 /* ParseCloudViewModelTests.swift */; }; 91F346C5269B88F7005727B6 /* ParseCloudViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F346C2269B88F7005727B6 /* ParseCloudViewModelTests.swift */; }; + CBEF514C295E40CB0052E598 /* LocalStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF514B295E40CB0052E598 /* LocalStorage.swift */; }; + CBEF514D295E40CB0052E598 /* LocalStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF514B295E40CB0052E598 /* LocalStorage.swift */; }; + CBEF514E295E40CB0052E598 /* LocalStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF514B295E40CB0052E598 /* LocalStorage.swift */; }; + CBEF514F295E40CB0052E598 /* LocalStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF514B295E40CB0052E598 /* LocalStorage.swift */; }; + CBEF5152295EF5DA0052E598 /* ParseLocalStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */; }; + CBEF5153295EF5E20052E598 /* ParseLocalStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */; }; + CBEF5154295EF5E30052E598 /* ParseLocalStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */; }; F971F4F624DE381A006CB79B /* ParseEncoderExtraTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F971F4F524DE381A006CB79B /* ParseEncoderExtraTests.swift */; }; F97B45CE24D9C6F200F4A88B /* ParseCoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B45B424D9C6F200F4A88B /* ParseCoding.swift */; }; F97B45CF24D9C6F200F4A88B /* ParseCoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B45B424D9C6F200F4A88B /* ParseCoding.swift */; }; @@ -1449,6 +1456,8 @@ 91F346B8269B766C005727B6 /* CloudViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudViewModel.swift; sourceTree = ""; }; 91F346BD269B77B5005727B6 /* CloudObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudObservable.swift; sourceTree = ""; }; 91F346C2269B88F7005727B6 /* ParseCloudViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseCloudViewModelTests.swift; sourceTree = ""; }; + CBEF514B295E40CB0052E598 /* LocalStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalStorage.swift; sourceTree = ""; }; + CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseLocalStorageTests.swift; sourceTree = ""; }; F971F4F524DE381A006CB79B /* ParseEncoderExtraTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseEncoderExtraTests.swift; sourceTree = ""; }; F97B45B424D9C6F200F4A88B /* ParseCoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParseCoding.swift; sourceTree = ""; }; F97B45B524D9C6F200F4A88B /* AnyDecodable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyDecodable.swift; sourceTree = ""; }; @@ -1686,6 +1695,7 @@ 70385E6328563FD10084D306 /* ParsePushPayloadFirebaseTests.swift */, 70212D172855256F00386163 /* ParsePushTests.swift */, 917BA4252703DB4600F8D747 /* ParseQueryAsyncTests.swift */, + CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */, 700AFE02289C3508006C1CD9 /* ParseQueryCacheTests.swift */, 7044C20525C5D6780011F6E7 /* ParseQueryCombineTests.swift */, 70C7DC1F24D20F180050419B /* ParseQueryTests.swift */, @@ -2225,6 +2235,7 @@ F97B45CB24D9C6F200F4A88B /* Storage */ = { isa = PBXGroup; children = ( + CBEF514B295E40CB0052E598 /* LocalStorage.swift */, F97B465E24D9C7B500F4A88B /* KeychainStore.swift */, 70572670259033A700F0ADD5 /* ParseFileManager.swift */, F97B45CC24D9C6F200F4A88B /* ParseStorage.swift */, @@ -2815,6 +2826,7 @@ F97B462F24D9C74400F4A88B /* BatchUtils.swift in Sources */, 70385E802858EAA90084D306 /* ParseHookFunctionRequest.swift in Sources */, 70CE0AAD28595FDE00DAEA86 /* ParseHookRequestable+combine.swift in Sources */, + CBEF514C295E40CB0052E598 /* LocalStorage.swift in Sources */, 4A82B7F61F254CCE0063D731 /* Parse.swift in Sources */, F97B45EA24D9C6F200F4A88B /* ParseGeoPoint.swift in Sources */, F97B460224D9C6F200F4A88B /* NoBody.swift in Sources */, @@ -2925,6 +2937,7 @@ 703B092326BDFAB2005A112F /* ParseUserAsyncTests.swift in Sources */, 70E6B016286120E00043EC4A /* ParseHookFunctionTests.swift in Sources */, 70C5504625B40D5200B5DBC2 /* ParseSessionTests.swift in Sources */, + CBEF5152295EF5DA0052E598 /* ParseLocalStorageTests.swift in Sources */, 70110D5C2506ED0E0091CC1D /* ParseInstallationTests.swift in Sources */, 70F03A562780E8E300E5AFB4 /* ParseGoogleCombineTests.swift in Sources */, 7C4C0947285EA60E00F202C6 /* ParseInstagramAsyncTests.swift in Sources */, @@ -3129,6 +3142,7 @@ 4AFDA72A1F26DAE1002AE4FC /* Parse.swift in Sources */, 70385E812858EAA90084D306 /* ParseHookFunctionRequest.swift in Sources */, 70CE0AAE28595FDE00DAEA86 /* ParseHookRequestable+combine.swift in Sources */, + CBEF514D295E40CB0052E598 /* LocalStorage.swift in Sources */, F97B45EB24D9C6F200F4A88B /* ParseGeoPoint.swift in Sources */, F97B460324D9C6F200F4A88B /* NoBody.swift in Sources */, 703B093126BF42C2005A112F /* ParseAnonymous+combine.swift in Sources */, @@ -3248,6 +3262,7 @@ 703B092526BDFAB2005A112F /* ParseUserAsyncTests.swift in Sources */, 70E6B018286120E00043EC4A /* ParseHookFunctionTests.swift in Sources */, 70C5504825B40D5200B5DBC2 /* ParseSessionTests.swift in Sources */, + CBEF5154295EF5E30052E598 /* ParseLocalStorageTests.swift in Sources */, 709B98572556ECAA00507778 /* ParseACLTests.swift in Sources */, 70F03A582780E8E300E5AFB4 /* ParseGoogleCombineTests.swift in Sources */, 7C4C0949285EA60E00F202C6 /* ParseInstagramAsyncTests.swift in Sources */, @@ -3372,6 +3387,7 @@ 703B092426BDFAB2005A112F /* ParseUserAsyncTests.swift in Sources */, 70E6B017286120E00043EC4A /* ParseHookFunctionTests.swift in Sources */, 70C5504725B40D5200B5DBC2 /* ParseSessionTests.swift in Sources */, + CBEF5153295EF5E20052E598 /* ParseLocalStorageTests.swift in Sources */, 70F2E2BC254F283000B2EA5C /* ParseObjectTests.swift in Sources */, 70F03A572780E8E300E5AFB4 /* ParseGoogleCombineTests.swift in Sources */, 7C4C0948285EA60E00F202C6 /* ParseInstagramAsyncTests.swift in Sources */, @@ -3576,6 +3592,7 @@ F97B465924D9C78C00F4A88B /* Remove.swift in Sources */, 70385E832858EAA90084D306 /* ParseHookFunctionRequest.swift in Sources */, 70CE0AB028595FDE00DAEA86 /* ParseHookRequestable+combine.swift in Sources */, + CBEF514F295E40CB0052E598 /* LocalStorage.swift in Sources */, 70110D5A2506CE890091CC1D /* BaseParseInstallation.swift in Sources */, F97B45F924D9C6F200F4A88B /* ParseError.swift in Sources */, 703B093326BF42C2005A112F /* ParseAnonymous+combine.swift in Sources */, @@ -3766,6 +3783,7 @@ F97B465824D9C78C00F4A88B /* Remove.swift in Sources */, 70385E822858EAA90084D306 /* ParseHookFunctionRequest.swift in Sources */, 70CE0AAF28595FDE00DAEA86 /* ParseHookRequestable+combine.swift in Sources */, + CBEF514E295E40CB0052E598 /* LocalStorage.swift in Sources */, 70110D592506CE890091CC1D /* BaseParseInstallation.swift in Sources */, F97B45F824D9C6F200F4A88B /* ParseError.swift in Sources */, 703B093226BF42C2005A112F /* ParseAnonymous+combine.swift in Sources */, diff --git a/Sources/ParseSwift/Coding/AnyDecodable.swift b/Sources/ParseSwift/Coding/AnyDecodable.swift index e7bb1f77e..adf872734 100755 --- a/Sources/ParseSwift/Coding/AnyDecodable.swift +++ b/Sources/ParseSwift/Coding/AnyDecodable.swift @@ -35,7 +35,7 @@ struct AnyDecodable: Decodable { } } -protocol _AnyDecodable { +protocol _AnyDecodable { // swiftlint:disable:this type_name var value: Any { get } init(_ value: T?) } @@ -74,6 +74,7 @@ extension _AnyDecodable { } extension AnyDecodable: Equatable { + // swiftlint:disable:next cyclomatic_complexity static func == (lhs: AnyDecodable, rhs: AnyDecodable) -> Bool { switch (lhs.value, rhs.value) { #if canImport(Foundation) diff --git a/Sources/ParseSwift/Coding/AnyEncodable.swift b/Sources/ParseSwift/Coding/AnyEncodable.swift index f7738bee4..876f968f9 100755 --- a/Sources/ParseSwift/Coding/AnyEncodable.swift +++ b/Sources/ParseSwift/Coding/AnyEncodable.swift @@ -38,8 +38,7 @@ struct AnyEncodable: Encodable { } @usableFromInline -protocol _AnyEncodable { - +protocol _AnyEncodable { // swiftlint:disable:this type_name var value: Any { get } init(_ value: T?) } @@ -47,7 +46,6 @@ protocol _AnyEncodable { extension AnyEncodable: _AnyEncodable {} // MARK: - Encodable - extension _AnyEncodable { // swiftlint:disable:next cyclomatic_complexity function_body_length func encode(to encoder: Encoder) throws { @@ -110,6 +108,7 @@ extension _AnyEncodable { } #if canImport(Foundation) + // swiftlint:disable:next cyclomatic_complexity private func encode(nsnumber: NSNumber, into container: inout SingleValueEncodingContainer) throws { switch Character(Unicode.Scalar(UInt8(nsnumber.objCType.pointee))) { case "c", "C": diff --git a/Sources/ParseSwift/Coding/ParseEncoder.swift b/Sources/ParseSwift/Coding/ParseEncoder.swift index 75b92a2a6..db3ef42c6 100644 --- a/Sources/ParseSwift/Coding/ParseEncoder.swift +++ b/Sources/ParseSwift/Coding/ParseEncoder.swift @@ -5,8 +5,7 @@ // Created by Pranjal Satija on 7/20/20. // Copyright © 2020 Parse. All rights reserved. // - -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project // @@ -16,10 +15,10 @@ // See https://swift.org/LICENSE.txt for license information // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // -//===----------------------------------------------------------------------===// - +// ===----------------------------------------------------------------------===// import Foundation +// swiftlint:disable type_name /// A marker protocol used to determine whether a value is a `String`-keyed `Dictionary` /// containing `Encodable` values (in which case it should be exempt from key conversion strategies). /// 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..c70616285 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..0225f517c 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 } @@ -1153,7 +1180,9 @@ extension ParseObject { */ @discardableResult public func save(ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options = []) throws -> Self { + let method = Method.save var childObjects: [String: PointerType]? var childFiles: [UUID: ParseFile]? var error: ParseError? @@ -1170,13 +1199,27 @@ extension ParseObject { group.wait() if let error = error { + if !ignoringLocalStore { + try? saveLocally(method: method, error: error) + } throw error } - return try saveCommand(ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig) - .execute(options: options, - childObjects: childObjects, - childFiles: childFiles) + do { + let commandResult = try saveCommand(ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig) + .execute(options: options, + childObjects: childObjects, + childFiles: childFiles) + if !ignoringLocalStore { + try? saveLocally(method: method) + } + return commandResult + } catch { + if !ignoringLocalStore { + try? saveLocally(method: method, error: error) + } + throw error + } } /** @@ -1185,6 +1228,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 +1246,7 @@ extension ParseObject { */ public func save( ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options = [], callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void @@ -1211,8 +1257,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 +1274,7 @@ extension ParseObject { #else command(method: method, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -1234,13 +1283,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 +1302,7 @@ extension ParseObject { Task { do { let object = try await command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(object)) @@ -1264,6 +1317,7 @@ extension ParseObject { } #else command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -1272,13 +1326,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 +1345,7 @@ extension ParseObject { Task { do { let object = try await command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(object)) @@ -1302,6 +1360,7 @@ extension ParseObject { } #else command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -1310,13 +1369,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 +1388,7 @@ extension ParseObject { Task { do { let object = try await command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue) completion(.success(object)) @@ -1340,6 +1403,7 @@ extension ParseObject { } #else command(method: method, + ignoringLocalStore: ignoringLocalStore, options: options, callbackQueue: callbackQueue, completion: completion) @@ -1348,6 +1412,7 @@ extension ParseObject { func command(method: Method, ignoringCustomObjectIdConfig: Bool = false, + ignoringLocalStore: Bool = false, options: API.Options, callbackQueue: DispatchQueue, completion: @escaping (Result) -> Void) { @@ -1371,16 +1436,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..827c6e7f6 --- /dev/null +++ b/Sources/ParseSwift/Storage/LocalStorage.swift @@ -0,0 +1,522 @@ +// +// 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 var MockLocalStorage: [any ParseObject]? + +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 { + if MockLocalStorage != nil { return mockedFetchObjects } + return try ParseCoding.jsonDecoder().decode([FetchObject].self, from: jsonData).uniqueObjectsById + } catch { + try fileManager.removeItem(at: fetchObjectsPath) + if MockLocalStorage != nil { return mockedFetchObjects } + return [] + } + } else { + if MockLocalStorage != nil { return mockedFetchObjects } + 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 private var mockedFetchObjects: [FetchObject] { + guard let mockLocalStorage = MockLocalStorage else { return [] } + return mockLocalStorage.compactMap({ try? FetchObject($0, method: .save) }) + } + + 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) + } + } + } + + if MockLocalStorage == nil { + 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: Error? = 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 where fetchObjectIds.contains(directoryObjectId) { + 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..fe800abcf 100644 --- a/Sources/ParseSwift/Storage/ParseFileManager.swift +++ b/Sources/ParseSwift/Storage/ParseFileManager.swift @@ -228,6 +228,34 @@ public extension ParseFileManager { 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. - parameter name: The name of the file to check. diff --git a/Sources/ParseSwift/Types/ParseACL.swift b/Sources/ParseSwift/Types/ParseACL.swift index 1fb34d44d..54798f5fd 100644 --- a/Sources/ParseSwift/Types/ParseACL.swift +++ b/Sources/ParseSwift/Types/ParseACL.swift @@ -8,6 +8,8 @@ import Foundation +// swiftlint:disable large_tuple + /** `ParseACL` is used to control which users can access or modify a particular `ParseObject`. Each `ParseObject` has its own ACL. You can grant read and write permissions separately diff --git a/Sources/ParseSwift/Types/ParseConfiguration.swift b/Sources/ParseSwift/Types/ParseConfiguration.swift index 1fdf8f92c..5093fc013 100644 --- a/Sources/ParseSwift/Types/ParseConfiguration.swift +++ b/Sources/ParseSwift/Types/ParseConfiguration.swift @@ -40,6 +40,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..387b4d7a8 100644 --- a/Sources/ParseSwift/Types/ParseError.swift +++ b/Sources/ParseSwift/Types/ParseError.swift @@ -349,6 +349,11 @@ public struct ParseError: ParseTypeable, Swift.Error { */ 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..d69726abd 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? @@ -45,6 +46,44 @@ public struct Query: ParseTypeable where T: ParseObject { 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]]? let hint: AnyCodable? @@ -436,6 +475,17 @@ public struct Query: ParseTypeable where T: ParseObject { 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 `ParseLiveQuery`. @@ -498,7 +548,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 +614,24 @@ 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 +752,34 @@ 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 +800,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 +872,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, diff --git a/Tests/ParseSwiftTests/ParseKeychainAccessGroupTests.swift b/Tests/ParseSwiftTests/ParseKeychainAccessGroupTests.swift index 3d4117f44..a26bb4b1d 100644 --- a/Tests/ParseSwiftTests/ParseKeychainAccessGroupTests.swift +++ b/Tests/ParseSwiftTests/ParseKeychainAccessGroupTests.swift @@ -11,6 +11,8 @@ import Foundation import XCTest @testable import ParseSwift +// swiftlint:disable unused_optional_binding function_body_length type_body_length + class ParseKeychainAccessGroupTests: XCTestCase { struct User: ParseUser { @@ -261,6 +263,7 @@ class ParseKeychainAccessGroupTests: XCTestCase { XCTAssertEqual(acl, otherAcl) } + // swiftlint:disable:next cyclomatic_complexity func testRemoveOldObjectsFromKeychain() throws { try userLogin() Config.current = .init(welcomeMessage: "yolo", winningNumber: 1) @@ -299,15 +302,15 @@ class ParseKeychainAccessGroupTests: XCTestCase { let deleted = KeychainStore.shared.removeOldObjects(accessGroup: ParseSwift.configuration.keychainAccessGroup) XCTAssertTrue(deleted) if let _: CurrentUserContainer = - try? KeychainStore.shared.get(valueFor: ParseStorage.Keys.currentUser) { + try? KeychainStore.shared.get(valueFor: ParseStorage.Keys.currentUser) { XCTFail("Should be nil") } if let _: CurrentConfigContainer = - try? KeychainStore.shared.get(valueFor: ParseStorage.Keys.currentConfig) { + try? KeychainStore.shared.get(valueFor: ParseStorage.Keys.currentConfig) { XCTFail("Should be nil") } if let _: DefaultACL = - try? KeychainStore.shared.get(valueFor: ParseStorage.Keys.defaultACL) { + try? KeychainStore.shared.get(valueFor: ParseStorage.Keys.defaultACL) { XCTFail("Should be nil") } guard let _: CurrentInstallationContainer = diff --git a/Tests/ParseSwiftTests/ParseLocalStorageTests.swift b/Tests/ParseSwiftTests/ParseLocalStorageTests.swift new file mode 100644 index 000000000..d181667aa --- /dev/null +++ b/Tests/ParseSwiftTests/ParseLocalStorageTests.swift @@ -0,0 +1,245 @@ +// +// ParseLocalStorageTests.swift +// ParseSwiftTests +// +// Created by Damian Van de Kauter on 30/12/2022. +// Copyright © 2022 Parse Community. All rights reserved. +// + +#if compiler(>=5.5.2) && canImport(_Concurrency) +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import XCTest +@testable import ParseSwift + +final class ParseLocalStorageTests: XCTestCase { + struct GameScore: ParseObject { + //: These are required by ParseObject + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + + //: Your own properties + var points: Int? + var player: String? + init() { } + //custom initializers + init (objectId: String?) { + self.objectId = objectId + } + init(points: Int) { + self.points = points + self.player = "Jen" + } + init(points: Int, name: String) { + self.points = points + self.player = name + } + } + + override func setUpWithError() throws { + try super.setUpWithError() + guard let url = URL(string: "http://localhost:1337/1") else { + XCTFail("Should create valid URL") + return + } + ParseSwift.initialize(applicationId: "applicationId", + clientKey: "clientKey", + masterKey: "masterKey", + serverURL: url, + offlinePolicy: .create, + requiringCustomObjectIds: true, + usingPostForQuery: true, + testing: true) + + var score1 = GameScore(points: 10) + score1.points = 11 + score1.objectId = "yolo1" + score1.createdAt = Date() + score1.updatedAt = score1.createdAt + score1.ACL = nil + + var score2 = GameScore(points: 10) + score2.points = 22 + score2.objectId = "yolo2" + score2.createdAt = Date() + score2.updatedAt = score2.createdAt + score2.ACL = nil + + MockLocalStorage = [score1, score2] + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + } + + @MainActor + func testFetchLocalStore() async throws { + try await GameScore.fetchLocalStore(GameScore.self) + } + + func testSave() throws { + var score = GameScore(points: 10) + score.points = 11 + score.objectId = "yolo" + score.createdAt = Date() + score.updatedAt = score.createdAt + score.ACL = nil + + let query = GameScore.query("objectId" == score.objectId) + .useLocalStore() + XCTAssertNotEqual(query.queryIdentifier, "") + + try LocalStorage.save(score, queryIdentifier: query.queryIdentifier) + } + + func testSaveAll() throws { + var score1 = GameScore(points: 10) + score1.points = 11 + score1.objectId = "yolo1" + score1.createdAt = Date() + score1.updatedAt = score1.createdAt + score1.ACL = nil + + var score2 = GameScore(points: 10) + score2.points = 22 + score2.objectId = "yolo2" + score2.createdAt = Date() + score2.updatedAt = score2.createdAt + score2.ACL = nil + + let query = GameScore.query(containedIn(key: "objectId", array: [score1, score2].map({ $0.objectId }))) + .useLocalStore() + XCTAssertNotEqual(query.queryIdentifier, "") + + try LocalStorage.saveAll([score1, score2], queryIdentifier: query.queryIdentifier) + } + + func testSaveCheckObjectId() throws { + var score1 = GameScore(points: 10) + score1.points = 11 + score1.createdAt = Date() + score1.updatedAt = score1.createdAt + score1.ACL = nil + + var score2 = GameScore(points: 10) + score2.points = 22 + score2.createdAt = Date() + score2.updatedAt = score2.createdAt + score2.ACL = nil + + let query = GameScore.query(containedIn(key: "objectId", array: [score1, score2].map({ $0.objectId }))) + .useLocalStore() + XCTAssertNotEqual(query.queryIdentifier, "") + + do { + try LocalStorage.saveAll([score1, score2], queryIdentifier: query.queryIdentifier) + } catch { + XCTAssertTrue(error.equalsTo(.missingObjectId)) + } + + do { + try LocalStorage.save(score1, queryIdentifier: query.queryIdentifier) + } catch { + XCTAssertTrue(error.equalsTo(.missingObjectId)) + } + } + + func testGet() throws { + let query = GameScore.query("objectId" == "yolo") + .useLocalStore() + XCTAssertNotEqual(query.queryIdentifier, "") + + XCTAssertNoThrow(try LocalStorage.get(GameScore.self, queryIdentifier: query.queryIdentifier)) + } + + func testGetAll() throws { + let query = GameScore.query(containedIn(key: "objectId", array: ["yolo1", "yolo2"])) + .useLocalStore() + XCTAssertNotEqual(query.queryIdentifier, "") + + XCTAssertNoThrow(try LocalStorage.getAll(GameScore.self, queryIdentifier: query.queryIdentifier)) + } + + func testSaveLocally() throws { + var score1 = GameScore(points: 10) + score1.points = 11 + score1.objectId = "yolo1" + score1.createdAt = Date() + score1.updatedAt = score1.createdAt + score1.ACL = nil + + var score2 = GameScore(points: 10) + score2.points = 22 + score2.objectId = "yolo2" + score2.createdAt = Date() + score2.updatedAt = score2.createdAt + score2.ACL = nil + + let query1 = GameScore.query("objectId" == "yolo1") + .useLocalStore() + let query2 = GameScore.query("objectId" == ["yolo1", "yolo2"]) + .useLocalStore() + + XCTAssertNoThrow(try score1.saveLocally(method: .save, + queryIdentifier: query1.queryIdentifier, + error: ParseError(code: .notConnectedToInternet, + message: ""))) + XCTAssertNoThrow(try score1.saveLocally(method: .save, + queryIdentifier: query1.queryIdentifier)) + + XCTAssertNoThrow(try [score1, score2].saveLocally(method: .save, + queryIdentifier: query2.queryIdentifier, + error: ParseError(code: .notConnectedToInternet, + message: ""))) + XCTAssertNoThrow(try [score1, score2].saveLocally(method: .save, + queryIdentifier: query2.queryIdentifier)) + + XCTAssertNoThrow(try score1.saveLocally(method: .create, + queryIdentifier: query1.queryIdentifier, + error: ParseError(code: .notConnectedToInternet, + message: ""))) + XCTAssertNoThrow(try score1.saveLocally(method: .create, + queryIdentifier: query1.queryIdentifier)) + + XCTAssertNoThrow(try [score1, score2].saveLocally(method: .create, + queryIdentifier: query2.queryIdentifier, + error: ParseError(code: .notConnectedToInternet, + message: ""))) + XCTAssertNoThrow(try [score1, score2].saveLocally(method: .create, + queryIdentifier: query2.queryIdentifier)) + + XCTAssertNoThrow(try score1.saveLocally(method: .replace, + queryIdentifier: query1.queryIdentifier, + error: ParseError(code: .notConnectedToInternet, + message: ""))) + XCTAssertNoThrow(try score1.saveLocally(method: .replace, + queryIdentifier: query1.queryIdentifier)) + + XCTAssertNoThrow(try [score1, score2].saveLocally(method: .replace, + queryIdentifier: query2.queryIdentifier, + error: ParseError(code: .notConnectedToInternet, + message: ""))) + XCTAssertNoThrow(try [score1, score2].saveLocally(method: .replace, + queryIdentifier: query2.queryIdentifier)) + + XCTAssertNoThrow(try score1.saveLocally(method: .update, + queryIdentifier: query1.queryIdentifier, + error: ParseError(code: .notConnectedToInternet, + message: ""))) + XCTAssertNoThrow(try score1.saveLocally(method: .update, + queryIdentifier: query1.queryIdentifier)) + + XCTAssertNoThrow(try [score1, score2].saveLocally(method: .update, + queryIdentifier: query2.queryIdentifier, + error: ParseError(code: .notConnectedToInternet, + message: ""))) + XCTAssertNoThrow(try [score1, score2].saveLocally(method: .update, + queryIdentifier: query2.queryIdentifier)) + } +} +#endif diff --git a/Tests/ParseSwiftTests/ParseQueryAsyncTests.swift b/Tests/ParseSwiftTests/ParseQueryAsyncTests.swift index d0e239a1a..2ecfc7d56 100644 --- a/Tests/ParseSwiftTests/ParseQueryAsyncTests.swift +++ b/Tests/ParseSwiftTests/ParseQueryAsyncTests.swift @@ -59,6 +59,7 @@ class ParseQueryAsyncTests: XCTestCase { // swiftlint:disable:this type_body_len clientKey: "clientKey", masterKey: "masterKey", serverURL: url, + offlinePolicy: .save, usingPostForQuery: true, testing: true) } @@ -102,6 +103,37 @@ class ParseQueryAsyncTests: XCTestCase { // swiftlint:disable:this type_body_len XCTAssert(object.hasSameObjectId(as: scoreOnServer)) } + @MainActor + func testLocalFind() async throws { + + var scoreOnServer = GameScore(points: 10) + scoreOnServer.points = 11 + scoreOnServer.objectId = "yolo" + scoreOnServer.createdAt = Date() + scoreOnServer.updatedAt = scoreOnServer.createdAt + scoreOnServer.ACL = nil + + let results = QueryResponse(results: [scoreOnServer], count: 1) + MockURLProtocol.mockRequests { _ in + do { + let encoded = try ParseCoding.jsonEncoder().encode(results) + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } catch { + return nil + } + } + + let query = GameScore.query + .useLocalStore() + + let found = try await query.find() + guard let object = found.first else { + XCTFail("Should have unwrapped") + return + } + XCTAssert(object.hasSameObjectId(as: scoreOnServer)) + } + @MainActor func testWithCount() async throws { @@ -201,6 +233,35 @@ class ParseQueryAsyncTests: XCTestCase { // swiftlint:disable:this type_body_len XCTAssert(object.hasSameObjectId(as: scoreOnServer)) } + @MainActor + func testLocalFindAll() async throws { + + var scoreOnServer = GameScore(points: 10) + scoreOnServer.objectId = "yarr" + scoreOnServer.createdAt = Date() + scoreOnServer.updatedAt = scoreOnServer.createdAt + scoreOnServer.ACL = nil + + let results = AnyResultsResponse(results: [scoreOnServer]) + MockURLProtocol.mockRequests { _ in + do { + let encoded = try ParseCoding.jsonEncoder().encode(results) + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } catch { + return nil + } + } + + let found = try await GameScore.query + .useLocalStore() + .findAll() + guard let object = found.first else { + XCTFail("Should have unwrapped") + return + } + XCTAssert(object.hasSameObjectId(as: scoreOnServer)) + } + @MainActor func testFindExplain() async throws { diff --git a/Tests/ParseSwiftTests/ParseQueryCacheTests.swift b/Tests/ParseSwiftTests/ParseQueryCacheTests.swift index 8bc24a04b..afebd2ac9 100644 --- a/Tests/ParseSwiftTests/ParseQueryCacheTests.swift +++ b/Tests/ParseSwiftTests/ParseQueryCacheTests.swift @@ -65,6 +65,7 @@ class ParseQueryCacheTests: XCTestCase { // swiftlint:disable:this type_body_len clientKey: "clientKey", masterKey: "masterKey", serverURL: url, + offlinePolicy: .save, usingEqualQueryConstraint: false, usingPostForQuery: false, testing: true) diff --git a/Tests/ParseSwiftTests/ParseQueryCombineTests.swift b/Tests/ParseSwiftTests/ParseQueryCombineTests.swift index 9d4d5031c..a8854d12e 100644 --- a/Tests/ParseSwiftTests/ParseQueryCombineTests.swift +++ b/Tests/ParseSwiftTests/ParseQueryCombineTests.swift @@ -57,6 +57,7 @@ class ParseQueryCombineTests: XCTestCase { // swiftlint:disable:this type_body_l clientKey: "clientKey", masterKey: "masterKey", serverURL: url, + offlinePolicy: .save, usingPostForQuery: true, testing: true) } diff --git a/Tests/ParseSwiftTests/ParseQueryTests.swift b/Tests/ParseSwiftTests/ParseQueryTests.swift index 26aef6c82..43388b6b6 100644 --- a/Tests/ParseSwiftTests/ParseQueryTests.swift +++ b/Tests/ParseSwiftTests/ParseQueryTests.swift @@ -63,6 +63,7 @@ class ParseQueryTests: XCTestCase { // swiftlint:disable:this type_body_length clientKey: "clientKey", masterKey: "masterKey", serverURL: url, + offlinePolicy: .save, usingEqualQueryConstraint: false, usingPostForQuery: true, testing: true) @@ -463,6 +464,38 @@ class ParseQueryTests: XCTestCase { // swiftlint:disable:this type_body_length } + func testLocalFind() { + var scoreOnServer = GameScore(points: 10) + scoreOnServer.objectId = "yarr" + scoreOnServer.createdAt = Date() + scoreOnServer.updatedAt = scoreOnServer.createdAt + scoreOnServer.ACL = nil + + let results = QueryResponse(results: [scoreOnServer], count: 1) + MockURLProtocol.mockRequests { _ in + do { + let encoded = try ParseCoding.jsonEncoder().encode(results) + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } catch { + return nil + } + } + + let query = GameScore.query() + .useLocalStore() + do { + + guard let score = try query.find(options: []).first else { + XCTFail("Should unwrap first object found") + return + } + XCTAssert(score.hasSameObjectId(as: scoreOnServer)) + } catch { + XCTFail(error.localizedDescription) + } + + } + func testFindLimit() { let query = GameScore.query() .limit(0) @@ -633,6 +666,44 @@ class ParseQueryTests: XCTestCase { // swiftlint:disable:this type_body_length wait(for: [expectation], timeout: 20.0) } + func testLocalFindAllAsync() { + var scoreOnServer = GameScore(points: 10) + scoreOnServer.objectId = "yarr" + scoreOnServer.createdAt = Date() + scoreOnServer.updatedAt = scoreOnServer.createdAt + scoreOnServer.ACL = nil + + let results = AnyResultsResponse(results: [scoreOnServer]) + MockURLProtocol.mockRequests { _ in + do { + let encoded = try ParseCoding.jsonEncoder().encode(results) + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } catch { + return nil + } + } + let query = GameScore.query() + .useLocalStore() + let expectation = XCTestExpectation(description: "Count object1") + query.findAll { result in + + switch result { + + case .success(let found): + guard let score = found.first else { + XCTFail("Should unwrap score count") + expectation.fulfill() + return + } + XCTAssert(score.hasSameObjectId(as: scoreOnServer)) + case .failure(let error): + XCTFail(error.localizedDescription) + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 20.0) + } + func testFindAllAsyncErrorSkip() { var scoreOnServer = GameScore(points: 10) scoreOnServer.objectId = "yarr" diff --git a/Tests/ParseSwiftTests/ParseQueryViewModelTests.swift b/Tests/ParseSwiftTests/ParseQueryViewModelTests.swift index 3d2ff27a9..566af6b04 100644 --- a/Tests/ParseSwiftTests/ParseQueryViewModelTests.swift +++ b/Tests/ParseSwiftTests/ParseQueryViewModelTests.swift @@ -44,6 +44,7 @@ class ParseQueryViewModelTests: XCTestCase { clientKey: "clientKey", masterKey: "masterKey", serverURL: url, + offlinePolicy: .save, usingPostForQuery: true, testing: true) }