diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f290ee7a..6b22c4f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/4.14.0...main) * _Contributing to this repo? Add info about your change here to be included in the next release_ +### 4.14.1 +[Full Changelog](https://github.com/parse-community/Parse-Swift/compare/4.14.0...4.14.1) + +__Fixes__ +- For Swift 5.5.2+ all asynchronous methods that attempt to save, create, update, or replace use the async/await version of deep saving ParseObjects. This fixes any purple warnings caused by the SDK in Xcode. Older Swift versions use the synchronous version of deep saving ([#418](https://github.com/parse-community/Parse-Swift/pull/418)), thanks to [Corey Baker](https://github.com/cbaker6). +- Can catch when the Parse Server throws an improper ParseError that only contains "error" or "message", but does not contain a "code" ([#418](https://github.com/parse-community/Parse-Swift/pull/418)), thanks to [Corey Baker](https://github.com/cbaker6). + ### 4.14.0 [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/4.13.1...4.14.0) diff --git a/ParseSwift.xcodeproj/project.pbxproj b/ParseSwift.xcodeproj/project.pbxproj index ac7552668..e1ad03430 100644 --- a/ParseSwift.xcodeproj/project.pbxproj +++ b/ParseSwift.xcodeproj/project.pbxproj @@ -469,6 +469,14 @@ 708D035325215F9B00646C70 /* Deletable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708D035125215F9B00646C70 /* Deletable.swift */; }; 708D035425215F9B00646C70 /* Deletable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708D035125215F9B00646C70 /* Deletable.swift */; }; 708D035525215F9B00646C70 /* Deletable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708D035125215F9B00646C70 /* Deletable.swift */; }; + 708EF0BD28D5F4140052EF35 /* API+Command+async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708EF0BC28D5F4140052EF35 /* API+Command+async.swift */; }; + 708EF0BE28D5F4140052EF35 /* API+Command+async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708EF0BC28D5F4140052EF35 /* API+Command+async.swift */; }; + 708EF0BF28D5F4140052EF35 /* API+Command+async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708EF0BC28D5F4140052EF35 /* API+Command+async.swift */; }; + 708EF0C028D5F4140052EF35 /* API+Command+async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708EF0BC28D5F4140052EF35 /* API+Command+async.swift */; }; + 708EF0C228D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708EF0C128D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift */; }; + 708EF0C328D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708EF0C128D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift */; }; + 708EF0C428D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708EF0C128D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift */; }; + 708EF0C528D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708EF0C128D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift */; }; 709A147D283949D100BF85E5 /* ParseSchema.swift in Sources */ = {isa = PBXBuildFile; fileRef = 709A147C283949D100BF85E5 /* ParseSchema.swift */; }; 709A147E283949D100BF85E5 /* ParseSchema.swift in Sources */ = {isa = PBXBuildFile; fileRef = 709A147C283949D100BF85E5 /* ParseSchema.swift */; }; 709A147F283949D100BF85E5 /* ParseSchema.swift in Sources */ = {isa = PBXBuildFile; fileRef = 709A147C283949D100BF85E5 /* ParseSchema.swift */; }; @@ -1285,6 +1293,8 @@ 7085DDB226D1EC7F0033B977 /* ParseAuthenticationCombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseAuthenticationCombineTests.swift; sourceTree = ""; }; 708CADCE2872263D0066C279 /* ParseKeychainAccessGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseKeychainAccessGroupTests.swift; sourceTree = ""; }; 708D035125215F9B00646C70 /* Deletable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Deletable.swift; sourceTree = ""; }; + 708EF0BC28D5F4140052EF35 /* API+Command+async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "API+Command+async.swift"; sourceTree = ""; }; + 708EF0C128D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "API+NonParseBodyCommand+async.swift"; sourceTree = ""; }; 709A147C283949D100BF85E5 /* ParseSchema.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseSchema.swift; sourceTree = ""; }; 709A148128395ED100BF85E5 /* ParseSchema+async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParseSchema+async.swift"; sourceTree = ""; }; 709A148628396B1C00BF85E5 /* ParseField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseField.swift; sourceTree = ""; }; @@ -2202,7 +2212,9 @@ F97B462624D9C72700F4A88B /* API.swift */, 91B79AC726EE3C5D00073F2C /* API+BatchCommand.swift */, F97B462E24D9C74400F4A88B /* API+Command.swift */, + 708EF0BC28D5F4140052EF35 /* API+Command+async.swift */, 91B79AC226EE3A4E00073F2C /* API+NonParseBodyCommand.swift */, + 708EF0C128D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift */, F97B462B24D9C74400F4A88B /* BatchUtils.swift */, 7003972925A3B0130052CB31 /* ParseURLSessionDelegate.swift */, F97B462D24D9C74400F4A88B /* Responses.swift */, @@ -2683,6 +2695,7 @@ 916786E2259B7DDA00BB5B4E /* ParseCloudable.swift in Sources */, 70CE0AC6285FD5A800DAEA86 /* ParseHookFunctionable+combine.swift in Sources */, 91F346B9269B766C005727B6 /* CloudViewModel.swift in Sources */, + 708EF0C228D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift in Sources */, 709A148C2839A1DB00BF85E5 /* Operation.swift in Sources */, 70CE0AD0285FD5D700DAEA86 /* ParseHookTriggerable+combine.swift in Sources */, 709A14A5283AAF4C00BF85E5 /* ParseSchema+combine.swift in Sources */, @@ -2701,6 +2714,7 @@ 70170A442656B02D0070C905 /* ParseAnalytics.swift in Sources */, 70110D52250680140091CC1D /* ParseConstants.swift in Sources */, 91B79AC326EE3A4E00073F2C /* API+NonParseBodyCommand.swift in Sources */, + 708EF0BD28D5F4140052EF35 /* API+Command+async.swift in Sources */, 70D1BDBA25BB17A600A42E7C /* ParseConfig.swift in Sources */, 7C4C092B285E746800F202C6 /* ParseInstagram.swift in Sources */, 703B08FD26BD953B005A112F /* ParseHealth+async.swift in Sources */, @@ -2995,6 +3009,7 @@ 916786E3259B7DDA00BB5B4E /* ParseCloudable.swift in Sources */, 70CE0AC7285FD5A800DAEA86 /* ParseHookFunctionable+combine.swift in Sources */, 91F346BA269B766D005727B6 /* CloudViewModel.swift in Sources */, + 708EF0C328D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift in Sources */, 709A148D2839A1DB00BF85E5 /* Operation.swift in Sources */, 70CE0AD1285FD5D700DAEA86 /* ParseHookTriggerable+combine.swift in Sources */, 709A14A6283AAF4C00BF85E5 /* ParseSchema+combine.swift in Sources */, @@ -3013,6 +3028,7 @@ 70170A452656B02D0070C905 /* ParseAnalytics.swift in Sources */, 70110D53250680140091CC1D /* ParseConstants.swift in Sources */, 91B79AC426EE3A4E00073F2C /* API+NonParseBodyCommand.swift in Sources */, + 708EF0BE28D5F4140052EF35 /* API+Command+async.swift in Sources */, 70D1BDBB25BB17A600A42E7C /* ParseConfig.swift in Sources */, 7C4C092C285E746800F202C6 /* ParseInstagram.swift in Sources */, 703B08FE26BD953B005A112F /* ParseHealth+async.swift in Sources */, @@ -3440,6 +3456,7 @@ 916786E5259B7DDA00BB5B4E /* ParseCloudable.swift in Sources */, 70CE0AC9285FD5A800DAEA86 /* ParseHookFunctionable+combine.swift in Sources */, 91F346BC269B766D005727B6 /* CloudViewModel.swift in Sources */, + 708EF0C528D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift in Sources */, 709A148F2839A1DB00BF85E5 /* Operation.swift in Sources */, 70CE0AD3285FD5D700DAEA86 /* ParseHookTriggerable+combine.swift in Sources */, 709A14A8283AAF4C00BF85E5 /* ParseSchema+combine.swift in Sources */, @@ -3458,6 +3475,7 @@ F97B460524D9C6F200F4A88B /* NoBody.swift in Sources */, 70170A472656B02D0070C905 /* ParseAnalytics.swift in Sources */, F97B45E124D9C6F200F4A88B /* AnyCodable.swift in Sources */, + 708EF0C028D5F4140052EF35 /* API+Command+async.swift in Sources */, 91B79AC626EE3A4E00073F2C /* API+NonParseBodyCommand.swift in Sources */, 7C4C092E285E746800F202C6 /* ParseInstagram.swift in Sources */, 70D1BDBD25BB17A600A42E7C /* ParseConfig.swift in Sources */, @@ -3628,6 +3646,7 @@ 916786E4259B7DDA00BB5B4E /* ParseCloudable.swift in Sources */, 70CE0AC8285FD5A800DAEA86 /* ParseHookFunctionable+combine.swift in Sources */, 91F346BB269B766D005727B6 /* CloudViewModel.swift in Sources */, + 708EF0C428D5FDF10052EF35 /* API+NonParseBodyCommand+async.swift in Sources */, 709A148E2839A1DB00BF85E5 /* Operation.swift in Sources */, 70CE0AD2285FD5D700DAEA86 /* ParseHookTriggerable+combine.swift in Sources */, 709A14A7283AAF4C00BF85E5 /* ParseSchema+combine.swift in Sources */, @@ -3646,6 +3665,7 @@ F97B460424D9C6F200F4A88B /* NoBody.swift in Sources */, 70170A462656B02D0070C905 /* ParseAnalytics.swift in Sources */, F97B45E024D9C6F200F4A88B /* AnyCodable.swift in Sources */, + 708EF0BF28D5F4140052EF35 /* API+Command+async.swift in Sources */, 91B79AC526EE3A4E00073F2C /* API+NonParseBodyCommand.swift in Sources */, 7C4C092D285E746800F202C6 /* ParseInstagram.swift in Sources */, 70D1BDBC25BB17A600A42E7C /* ParseConfig.swift in Sources */, diff --git a/Sources/ParseSwift/API/API+Command+async.swift b/Sources/ParseSwift/API/API+Command+async.swift new file mode 100644 index 000000000..138ad0140 --- /dev/null +++ b/Sources/ParseSwift/API/API+Command+async.swift @@ -0,0 +1,37 @@ +// +// API+Command+async.swift +// ParseSwift +// +// Created by Corey Baker on 9/17/22. +// Copyright © 2022 Parse Community. All rights reserved. +// + +#if compiler(>=5.5.2) && canImport(_Concurrency) +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +internal extension API.Command { + // MARK: Asynchronous Execution + func executeAsync(options: API.Options, + callbackQueue: DispatchQueue, + notificationQueue: DispatchQueue? = nil, + childObjects: [String: PointerType]? = nil, + childFiles: [UUID: ParseFile]? = nil, + uploadProgress: ((URLSessionTask, Int64, Int64, Int64) -> Void)? = nil, + // swiftlint:disable:next line_length + downloadProgress: ((URLSessionDownloadTask, Int64, Int64, Int64) -> Void)? = nil) async throws -> U { + try await withCheckedThrowingContinuation { continuation in + self.executeAsync(options: options, + callbackQueue: callbackQueue, + notificationQueue: notificationQueue, + childObjects: childObjects, + childFiles: childFiles, + uploadProgress: uploadProgress, + downloadProgress: downloadProgress, + completion: continuation.resume) + } + } +} +#endif diff --git a/Sources/ParseSwift/API/API+NonParseBodyCommand+async.swift b/Sources/ParseSwift/API/API+NonParseBodyCommand+async.swift new file mode 100644 index 000000000..36bcd4750 --- /dev/null +++ b/Sources/ParseSwift/API/API+NonParseBodyCommand+async.swift @@ -0,0 +1,26 @@ +// +// API+NonParseBodyCommand+async.swift +// ParseSwift +// +// Created by Corey Baker on 9/17/22. +// Copyright © 2022 Parse Community. All rights reserved. +// + +#if compiler(>=5.5.2) && canImport(_Concurrency) +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension API.NonParseBodyCommand { + // MARK: Asynchronous Execution + func executeAsync(options: API.Options, + callbackQueue: DispatchQueue) async throws -> U { + try await withCheckedThrowingContinuation { continuation in + self.executeAsync(options: options, + callbackQueue: callbackQueue, + completion: continuation.resume) + } + } +} +#endif diff --git a/Sources/ParseSwift/Objects/ParseInstallation+async.swift b/Sources/ParseSwift/Objects/ParseInstallation+async.swift index f55d7a7e3..7cc5b2ff3 100644 --- a/Sources/ParseSwift/Objects/ParseInstallation+async.swift +++ b/Sources/ParseSwift/Objects/ParseInstallation+async.swift @@ -328,6 +328,118 @@ public extension Sequence where Element: ParseInstallation { } } +// MARK: Helper Methods (Internal) +internal extension ParseInstallation { + + func command(method: Method, + ignoringCustomObjectIdConfig: Bool = false, + options: API.Options, + callbackQueue: DispatchQueue) async throws -> Self { + let (savedChildObjects, savedChildFiles) = try await self.ensureDeepSave(options: options) + do { + let command: API.Command! + switch method { + case .save: + command = try self.saveCommand(ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig) + case .create: + command = self.createCommand() + case .replace: + command = try self.replaceCommand() + case .update: + command = try self.updateCommand() + } + let saved = try await command + .executeAsync(options: options, + callbackQueue: callbackQueue, + childObjects: savedChildObjects, + childFiles: savedChildFiles) + try? Self.updateKeychainIfNeeded([saved]) + return saved + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } +} + +// MARK: Batch Support +internal extension Sequence where Element: ParseInstallation { + func batchCommand(method: Method, + batchLimit limit: Int?, + transaction: Bool, + ignoringCustomObjectIdConfig: Bool = false, + options: API.Options, + callbackQueue: DispatchQueue) async throws -> [(Result)] { + var options = options + options.insert(.cachePolicy(.reloadIgnoringLocalCacheData)) + var childObjects = [String: PointerType]() + var childFiles = [UUID: ParseFile]() + var commands = [API.Command]() + let objects = map { $0 } + for object in objects { + let (savedChildObjects, savedChildFiles) = try await object + .ensureDeepSave(options: options, + isShouldReturnIfChildObjectsFound: transaction) + try savedChildObjects.forEach {(key, value) in + guard childObjects[key] == nil else { + throw ParseError(code: .unknownError, message: "circular dependency") + } + childObjects[key] = value + } + try savedChildFiles.forEach {(key, value) in + guard childFiles[key] == nil else { + throw ParseError(code: .unknownError, message: "circular dependency") + } + childFiles[key] = value + } + do { + switch method { + case .save: + commands.append( + try object.saveCommand(ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig) + ) + case .create: + commands.append(object.createCommand()) + case .replace: + commands.append(try object.replaceCommand()) + case .update: + commands.append(try object.updateCommand()) + } + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } + + do { + var returnBatch = [(Result)]() + let batchLimit = limit != nil ? limit! : ParseConstants.batchLimit + try canSendTransactions(transaction, objectCount: commands.count, batchLimit: batchLimit) + let batches = BatchUtils.splitArray(commands, valuesPerSegment: batchLimit) + for batch in batches { + let saved = try await API.Command + .batch(commands: batch, transaction: transaction) + .executeAsync(options: options, + callbackQueue: callbackQueue, + childObjects: childObjects, + childFiles: childFiles) + returnBatch.append(contentsOf: saved) + } + try? Self.Element.updateKeychainIfNeeded(returnBatch.compactMap {try? $0.get()}) + return returnBatch + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } +} + #if !os(Linux) && !os(Android) && !os(Windows) // MARK: Migrate from Objective-C SDK public extension ParseInstallation { diff --git a/Sources/ParseSwift/Objects/ParseInstallation.swift b/Sources/ParseSwift/Objects/ParseInstallation.swift index b82b11603..bc4c8e415 100644 --- a/Sources/ParseSwift/Objects/ParseInstallation.swift +++ b/Sources/ParseSwift/Objects/ParseInstallation.swift @@ -692,11 +692,31 @@ extension ParseInstallation { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .save, + let method = Method.save + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -714,10 +734,29 @@ extension ParseInstallation { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .create, + let method = Method.create + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -736,10 +775,29 @@ extension ParseInstallation { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .replace, + let method = Method.replace + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -758,10 +816,29 @@ extension ParseInstallation { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .update, + let method = Method.update + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } func command( @@ -1119,13 +1196,35 @@ public extension Sequence where Element: ParseInstallation { callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .save, + let method = Method.save + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1152,12 +1251,33 @@ public extension Sequence where Element: ParseInstallation { callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .create, + let method = Method.create + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1185,12 +1305,33 @@ public extension Sequence where Element: ParseInstallation { callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .replace, + let method = Method.replace + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1218,12 +1359,33 @@ public extension Sequence where Element: ParseInstallation { callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .update, + let method = Method.update + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } internal func batchCommand( // swiftlint:disable:this function_parameter_count @@ -1258,30 +1420,28 @@ public extension Sequence where Element: ParseInstallation { // swiftlint:disable:next line_length isShouldReturnIfChildObjectsFound: transaction) { (savedChildObjects, savedChildFiles, parseError) -> Void in // If an error occurs, everything should be skipped - if parseError != nil { + if let parseError = parseError { error = parseError } savedChildObjects.forEach {(key, value) in - if error != nil { + guard error == nil else { return } - if childObjects[key] == nil { - childObjects[key] = value - } else { + guard childObjects[key] == nil else { error = ParseError(code: .unknownError, message: "circular dependency") return } + childObjects[key] = value } savedChildFiles.forEach {(key, value) in - if error != nil { + guard error == nil else { return } - if childFiles[key] == nil { - childFiles[key] = value - } else { + guard childFiles[key] == nil else { error = ParseError(code: .unknownError, message: "circular dependency") return } + childFiles[key] = value } group.leave() } diff --git a/Sources/ParseSwift/Objects/ParseObject+async.swift b/Sources/ParseSwift/Objects/ParseObject+async.swift index 4fedae25c..bc43c7be0 100644 --- a/Sources/ParseSwift/Objects/ParseObject+async.swift +++ b/Sources/ParseSwift/Objects/ParseObject+async.swift @@ -278,4 +278,206 @@ public extension Sequence where Element: ParseObject { } } } + +// MARK: Helper Methods (Internal) +internal extension ParseObject { + + // swiftlint:disable:next function_body_length + func ensureDeepSave(options: API.Options = [], + isShouldReturnIfChildObjectsFound: Bool = false) async throws -> ([String: PointerType], + [UUID: ParseFile]) { + + var options = options + // Remove any caching policy added by the developer as fresh data + // from the server is needed. + options.remove(.cachePolicy(.reloadIgnoringLocalCacheData)) + options.insert(.cachePolicy(.reloadIgnoringLocalCacheData)) + var objectsFinishedSaving = [String: PointerType]() + var filesFinishedSaving = [UUID: ParseFile]() + do { + let object = try ParseCoding.parseEncoder() + .encode(self, + objectsSavedBeforeThisOne: nil, + filesSavedBeforeThisOne: nil) + var waitingToBeSaved = object.unsavedChildren + if isShouldReturnIfChildObjectsFound && waitingToBeSaved.count > 0 { + let error = ParseError(code: .unknownError, + message: """ +When using transactions, all child ParseObjects have to originally +be saved to the Parse Server. Either save all child objects first +or disable transactions for this call. +""") + throw error + } + while waitingToBeSaved.count > 0 { + var savableObjects = [ParseEncodable]() + var savableFiles = [ParseFile]() + var nextBatch = [ParseEncodable]() + try waitingToBeSaved.forEach { parseType in + if let parseFile = parseType as? ParseFile { + // ParseFiles can be saved now + savableFiles.append(parseFile) + } else if let parseObject = parseType as? Objectable { + // This is a ParseObject + let waitingObjectInfo = try ParseCoding + .parseEncoder() + .encode(parseObject, + collectChildren: true, + objectsSavedBeforeThisOne: objectsFinishedSaving, + filesSavedBeforeThisOne: filesFinishedSaving) + if waitingObjectInfo.unsavedChildren.count == 0 { + //If this ParseObject has no additional children, it can be saved now + savableObjects.append(parseObject) + } else { + //Else this ParseObject needs to wait until it is children are saved + nextBatch.append(parseObject) + } + } + } + waitingToBeSaved = nextBatch + if waitingToBeSaved.count > 0 && savableObjects.count == 0 && savableFiles.count == 0 { + throw ParseError(code: .unknownError, + message: "Found a circular dependency in ParseObject.") + } + if savableObjects.count > 0 { + let savedChildObjects = try await self.saveAll(objects: savableObjects, + options: options) + let savedChildPointers = try savedChildObjects.compactMap { try $0.get() } + for (index, object) in savableObjects.enumerated() { + let hash = try BaseObjectable.createHash(object) + objectsFinishedSaving[hash] = savedChildPointers[index] + } + } + for savableFile in savableFiles { + filesFinishedSaving[savableFile.id] = try await savableFile.save(options: options) + } + } + return (objectsFinishedSaving, filesFinishedSaving) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } + + func command(method: Method, + ignoringCustomObjectIdConfig: Bool = false, + options: API.Options, + callbackQueue: DispatchQueue) async throws -> Self { + let (savedChildObjects, savedChildFiles) = try await self.ensureDeepSave(options: options) + do { + let command: API.Command! + switch method { + case .save: + command = try self.saveCommand(ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig) + case .create: + command = self.createCommand() + case .replace: + command = try self.replaceCommand() + case .update: + command = try self.updateCommand() + } + return try await command + .executeAsync(options: options, + callbackQueue: callbackQueue, + childObjects: savedChildObjects, + childFiles: savedChildFiles) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } +} + +// MARK: Batch Support +internal extension Sequence where Element: ParseObject { + func batchCommand(method: Method, + batchLimit limit: Int?, + transaction: Bool, + ignoringCustomObjectIdConfig: Bool = false, + options: API.Options, + callbackQueue: DispatchQueue) async throws -> [(Result)] { + var options = options + options.insert(.cachePolicy(.reloadIgnoringLocalCacheData)) + var childObjects = [String: PointerType]() + var childFiles = [UUID: ParseFile]() + var commands = [API.Command]() + let objects = map { $0 } + for object in objects { + let (savedChildObjects, savedChildFiles) = try await object + .ensureDeepSave(options: options, + isShouldReturnIfChildObjectsFound: transaction) + try savedChildObjects.forEach {(key, value) in + guard childObjects[key] == nil else { + throw ParseError(code: .unknownError, message: "circular dependency") + } + childObjects[key] = value + } + try savedChildFiles.forEach {(key, value) in + guard childFiles[key] == nil else { + throw ParseError(code: .unknownError, message: "circular dependency") + } + childFiles[key] = value + } + do { + switch method { + case .save: + commands.append( + try object.saveCommand(ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig) + ) + case .create: + commands.append(object.createCommand()) + case .replace: + commands.append(try object.replaceCommand()) + case .update: + commands.append(try object.updateCommand()) + } + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } + + do { + var returnBatch = [(Result)]() + let batchLimit = limit != nil ? limit! : ParseConstants.batchLimit + try canSendTransactions(transaction, objectCount: commands.count, batchLimit: batchLimit) + let batches = BatchUtils.splitArray(commands, valuesPerSegment: batchLimit) + for batch in batches { + let saved = try await API.Command + .batch(commands: batch, transaction: transaction) + .executeAsync(options: options, + callbackQueue: callbackQueue, + childObjects: childObjects, + childFiles: childFiles) + returnBatch.append(contentsOf: saved) + } + return returnBatch + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } +} + +// MARK: Savable Encodable Version +internal extension ParseEncodable { + func saveAll(objects: [ParseEncodable], + transaction: Bool = configuration.isUsingTransactions, + options: API.Options = [], + callbackQueue: DispatchQueue = .main) async throws -> [(Result)] { + try await API.NonParseBodyCommand + .batch(objects: objects, + transaction: transaction) + .executeAsync(options: options, + callbackQueue: callbackQueue) + } +} #endif diff --git a/Sources/ParseSwift/Objects/ParseObject.swift b/Sources/ParseSwift/Objects/ParseObject.swift index 81bff03ec..58a8b490b 100644 --- a/Sources/ParseSwift/Objects/ParseObject.swift +++ b/Sources/ParseSwift/Objects/ParseObject.swift @@ -504,13 +504,35 @@ transactions for this call. callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .save, + let method = Method.save + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -537,12 +559,33 @@ transactions for this call. callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .create, + let method = Method.create + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -569,12 +612,33 @@ transactions for this call. callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .replace, + let method = Method.replace + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -601,12 +665,33 @@ transactions for this call. callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .update, + let method = Method.update + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } internal func batchCommand(method: Method, // swiftlint:disable:this function_parameter_count @@ -637,30 +722,28 @@ transactions for this call. // swiftlint:disable:next line_length isShouldReturnIfChildObjectsFound: transaction) { (savedChildObjects, savedChildFiles, parseError) -> Void in // If an error occurs, everything should be skipped - if parseError != nil { + if let parseError = parseError { error = parseError } savedChildObjects.forEach {(key, value) in - if error != nil { + guard error == nil else { return } - if childObjects[key] == nil { - childObjects[key] = value - } else { + guard childObjects[key] == nil else { error = ParseError(code: .unknownError, message: "circular dependency") return } + childObjects[key] = value } savedChildFiles.forEach {(key, value) in - if error != nil { + guard error == nil else { return } - if childFiles[key] == nil { - childFiles[key] = value - } else { + guard childFiles[key] == nil else { error = ParseError(code: .unknownError, message: "circular dependency") return } + childFiles[key] = value } group.leave() } @@ -1120,11 +1203,31 @@ extension ParseObject { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .save, + let method = Method.save + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1140,10 +1243,29 @@ extension ParseObject { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .create, + let method = Method.create + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1159,10 +1281,29 @@ extension ParseObject { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .replace, + let method = Method.replace + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1178,10 +1319,29 @@ extension ParseObject { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .update, + let method = Method.update + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } func command(method: Method, @@ -1318,7 +1478,6 @@ extension ParseObject { message: "Found a circular dependency in ParseObject.")) return } - if savableObjects.count > 0 { let savedChildObjects = try self.saveAll(objects: savableObjects, options: options) diff --git a/Sources/ParseSwift/Objects/ParseUser+async.swift b/Sources/ParseSwift/Objects/ParseUser+async.swift index ca61c5fb8..9f442d984 100644 --- a/Sources/ParseSwift/Objects/ParseUser+async.swift +++ b/Sources/ParseSwift/Objects/ParseUser+async.swift @@ -503,4 +503,115 @@ public extension Sequence where Element: ParseUser { } } +// MARK: Helper Methods (Internal) +internal extension ParseUser { + + func command(method: Method, + ignoringCustomObjectIdConfig: Bool = false, + options: API.Options, + callbackQueue: DispatchQueue) async throws -> Self { + let (savedChildObjects, savedChildFiles) = try await self.ensureDeepSave(options: options) + do { + let command: API.Command! + switch method { + case .save: + command = try self.saveCommand(ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig) + case .create: + command = self.createCommand() + case .replace: + command = try self.replaceCommand() + case .update: + command = try self.updateCommand() + } + let saved = try await command + .executeAsync(options: options, + callbackQueue: callbackQueue, + childObjects: savedChildObjects, + childFiles: savedChildFiles) + try? Self.updateKeychainIfNeeded([saved]) + return saved + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } +} + +// MARK: Batch Support +internal extension Sequence where Element: ParseUser { + func batchCommand(method: Method, + batchLimit limit: Int?, + transaction: Bool, + ignoringCustomObjectIdConfig: Bool = false, + options: API.Options, + callbackQueue: DispatchQueue) async throws -> [(Result)] { + var options = options + options.insert(.cachePolicy(.reloadIgnoringLocalCacheData)) + var childObjects = [String: PointerType]() + var childFiles = [UUID: ParseFile]() + var commands = [API.Command]() + let objects = map { $0 } + for object in objects { + let (savedChildObjects, savedChildFiles) = try await object + .ensureDeepSave(options: options, + isShouldReturnIfChildObjectsFound: transaction) + try savedChildObjects.forEach {(key, value) in + guard childObjects[key] == nil else { + throw ParseError(code: .unknownError, message: "circular dependency") + } + childObjects[key] = value + } + try savedChildFiles.forEach {(key, value) in + guard childFiles[key] == nil else { + throw ParseError(code: .unknownError, message: "circular dependency") + } + childFiles[key] = value + } + do { + switch method { + case .save: + commands.append( + try object.saveCommand(ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig) + ) + case .create: + commands.append(object.createCommand()) + case .replace: + commands.append(try object.replaceCommand()) + case .update: + commands.append(try object.updateCommand()) + } + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } + + do { + var returnBatch = [(Result)]() + let batchLimit = limit != nil ? limit! : ParseConstants.batchLimit + try canSendTransactions(transaction, objectCount: commands.count, batchLimit: batchLimit) + let batches = BatchUtils.splitArray(commands, valuesPerSegment: batchLimit) + for batch in batches { + let saved = try await API.Command + .batch(commands: batch, transaction: transaction) + .executeAsync(options: options, + callbackQueue: callbackQueue, + childObjects: childObjects, + childFiles: childFiles) + returnBatch.append(contentsOf: saved) + } + try? Self.Element.updateKeychainIfNeeded(returnBatch.compactMap {try? $0.get()}) + return returnBatch + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + throw parseError + } + } +} #endif diff --git a/Sources/ParseSwift/Objects/ParseUser.swift b/Sources/ParseSwift/Objects/ParseUser.swift index 94b8ce2c8..4eae6c16f 100644 --- a/Sources/ParseSwift/Objects/ParseUser.swift +++ b/Sources/ParseSwift/Objects/ParseUser.swift @@ -1107,11 +1107,31 @@ extension ParseUser { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .save, + let method = Method.save + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1127,10 +1147,29 @@ extension ParseUser { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .create, + let method = Method.create + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1147,10 +1186,29 @@ extension ParseUser { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .replace, + let method = Method.replace + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1167,10 +1225,29 @@ extension ParseUser { callbackQueue: DispatchQueue = .main, completion: @escaping (Result) -> Void ) { - command(method: .update, + let method = Method.update + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let object = try await command(method: method, + options: options, + callbackQueue: callbackQueue) + completion(.success(object)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + command(method: method, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } func command( @@ -1549,13 +1626,35 @@ public extension Sequence where Element: ParseUser { callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .save, + let method = Method.save + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, ignoringCustomObjectIdConfig: ignoringCustomObjectIdConfig, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1582,12 +1681,33 @@ public extension Sequence where Element: ParseUser { callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .create, + let method = Method.create + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1615,12 +1735,33 @@ public extension Sequence where Element: ParseUser { callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .replace, + let method = Method.replace + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } /** @@ -1648,12 +1789,33 @@ public extension Sequence where Element: ParseUser { callbackQueue: DispatchQueue = .main, completion: @escaping (Result<[(Result)], ParseError>) -> Void ) { - batchCommand(method: .update, + let method = Method.update + #if compiler(>=5.5.2) && canImport(_Concurrency) + Task { + do { + let objects = try await batchCommand(method: method, + batchLimit: limit, + transaction: transaction, + options: options, + callbackQueue: callbackQueue) + completion(.success(objects)) + } catch { + let defaultError = ParseError(code: .unknownError, + message: error.localizedDescription) + let parseError = error as? ParseError ?? defaultError + callbackQueue.async { + completion(.failure(parseError)) + } + } + } + #else + batchCommand(method: method, batchLimit: limit, transaction: transaction, options: options, callbackQueue: callbackQueue, completion: completion) + #endif } internal func batchCommand( // swiftlint:disable:this function_parameter_count @@ -1687,30 +1849,28 @@ public extension Sequence where Element: ParseUser { // swiftlint:disable:next line_length isShouldReturnIfChildObjectsFound: transaction) { (savedChildObjects, savedChildFiles, parseError) -> Void in // If an error occurs, everything should be skipped - if parseError != nil { + if let parseError = parseError { error = parseError } savedChildObjects.forEach {(key, value) in - if error != nil { + guard error == nil else { return } - if childObjects[key] == nil { - childObjects[key] = value - } else { + guard childObjects[key] == nil else { error = ParseError(code: .unknownError, message: "circular dependency") return } + childObjects[key] = value } savedChildFiles.forEach {(key, value) in - if error != nil { + guard error == nil else { return } - if childFiles[key] == nil { - childFiles[key] = value - } else { + guard childFiles[key] == nil else { error = ParseError(code: .unknownError, message: "circular dependency") return } + childFiles[key] = value } group.leave() } diff --git a/Sources/ParseSwift/ParseConstants.swift b/Sources/ParseSwift/ParseConstants.swift index 2cb03a300..0335f55a5 100644 --- a/Sources/ParseSwift/ParseConstants.swift +++ b/Sources/ParseSwift/ParseConstants.swift @@ -10,7 +10,7 @@ import Foundation enum ParseConstants { static let sdk = "swift" - static let version = "4.14.0" + static let version = "4.14.1" static let fileManagementDirectory = "parse/" static let fileManagementPrivateDocumentsDirectory = "Private Documents/" static let fileManagementLibraryDirectory = "Library/" diff --git a/Sources/ParseSwift/Types/ParseError.swift b/Sources/ParseSwift/Types/ParseError.swift index 3b0347e10..2667cde98 100644 --- a/Sources/ParseSwift/Types/ParseError.swift +++ b/Sources/ParseSwift/Types/ParseError.swift @@ -403,8 +403,13 @@ extension ParseError { code = try values.decode(Code.self, forKey: .code) otherCode = nil } catch { - code = .other - otherCode = try values.decode(Int.self, forKey: .code) + do { + otherCode = try values.decode(Int.self, forKey: .code) + code = .other + } catch { + code = .unknownError + otherCode = nil + } } // Handle when Parse Server sends "message" instead of "error". do { diff --git a/Tests/ParseSwiftTests/ParseErrorTests.swift b/Tests/ParseSwiftTests/ParseErrorTests.swift index c66e88b06..8cf22590b 100644 --- a/Tests/ParseSwiftTests/ParseErrorTests.swift +++ b/Tests/ParseSwiftTests/ParseErrorTests.swift @@ -43,7 +43,7 @@ class ParseErrorTests: XCTestCase { XCTAssertTrue(error2.description.contains(expected2)) } - func testEncode() throws { + func testDecode() throws { let code = -1 let message = "testing ParseError" guard let encoded: Data = "{\"error\":\"\(message)\",\"code\":\(code)}".data(using: .utf8) else { @@ -59,7 +59,7 @@ class ParseErrorTests: XCTestCase { XCTAssertEqual(decoded.errorDescription, "ParseError code=\(code) error=\(message)") } - func testEncodeMessage() throws { + func testDecodeMessage() throws { let code = -1 let message = "testing ParseError" guard let encoded: Data = "{\"message\":\"\(message)\",\"code\":\(code)}".data(using: .utf8) else { @@ -75,7 +75,7 @@ class ParseErrorTests: XCTestCase { XCTAssertEqual(decoded.errorDescription, "ParseError code=\(code) error=\(message)") } - func testEncodeOther() throws { + func testDecodeOther() throws { let code = 2000 let message = "testing ParseError" guard let encoded = "{\"error\":\"\(message)\",\"code\":\(code)}".data(using: .utf8) else { @@ -91,6 +91,22 @@ class ParseErrorTests: XCTestCase { XCTAssertEqual(decoded.otherCode, code) } + func testDecodeMissingCode() throws { + let code = -1 + let message = "testing ParseError" + guard let encoded = "{\"error\":\"\(message)\"}".data(using: .utf8) else { + XCTFail("Should have unwrapped") + return + } + let decoded = try ParseCoding.jsonDecoder().decode(ParseError.self, from: encoded) + XCTAssertEqual(decoded.code, .unknownError) + XCTAssertEqual(decoded.message, message) + XCTAssertNil(decoded.error) + XCTAssertEqual(decoded.debugDescription, + "ParseError code=\(code) error=\(message)") + XCTAssertNil(decoded.otherCode) + } + func testCompare() throws { let code = ParseError.Code.objectNotFound.rawValue let message = "testing ParseError" diff --git a/Tests/ParseSwiftTests/ParseObjectAsyncTests.swift b/Tests/ParseSwiftTests/ParseObjectAsyncTests.swift index 23cd91f5f..afc4ba63c 100644 --- a/Tests/ParseSwiftTests/ParseObjectAsyncTests.swift +++ b/Tests/ParseSwiftTests/ParseObjectAsyncTests.swift @@ -28,6 +28,9 @@ class ParseObjectAsyncTests: XCTestCase { // swiftlint:disable:this type_body_le //: Your own properties var points: Int? var player: String? + var level: Level? + var levels: [Level]? + var nextLevel: Level? //: Implement your own version of merge func merge(with object: Self) throws -> Self { @@ -71,9 +74,6 @@ class ParseObjectAsyncTests: XCTestCase { // swiftlint:disable:this type_body_le var name: String? var originalData: Data? - - init() { - } } struct GameScoreDefaultMerge: ParseObject { @@ -117,6 +117,211 @@ class ParseObjectAsyncTests: XCTestCase { // swiftlint:disable:this type_body_le var originalData: Data? } + struct Game: 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 gameScore: GameScore + var gameScores = [GameScore]() + var name = "Hello" + var profilePicture: ParseFile? + + //: a custom initializer + init() { + self.gameScore = GameScore() + } + + init(gameScore: GameScore) { + self.gameScore = gameScore + } + } + + struct Game2: 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 name = "Hello" + var profilePicture: ParseFile? + } + + final class GameScoreClass: 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 = "Jen" + var level: Level? + var levels: [Level]? + var game: GameClass? + + //: a custom initializer + required init() { + self.points = 5 + } + + init(points: Int) { + self.points = points + } + + /** + Conforms to Equatable by determining if an object has the same objectId. + - note: You can specify a custom way of `Equatable` if a more detailed way is needed. + - warning: If you use the default implementation, equatable will only work if the ParseObject + has been previously synced to the parse-server (has an objectId). In addition, if two + `ParseObject`'s have the same objectId, but were modified at different times, the + default implementation will still return true. In these cases you either want to use a + "struct" (value types) to make your `ParseObject`s instead of a class (reference type) or + provide your own implementation of `==`. + - parameter lhs: first object to compare + - parameter rhs: second object to compare + + - returns: Returns a **true** if the other object has the same `objectId` or **false** if unsuccessful. + */ + public static func == (lhs: ParseObjectAsyncTests.GameScoreClass, + rhs: ParseObjectAsyncTests.GameScoreClass) -> Bool { + lhs.hasSameObjectId(as: rhs) + } + + /** + Conforms to `Hashable` using objectId. + - note: You can specify a custom way of `Hashable` if a more detailed way is needed. + - warning: If you use the default implementation, hash will only work if the ParseObject has been previously + synced to the parse-server (has an objectId). In addition, if two `ParseObject`'s have the same objectId, + but were modified at different times, the default implementation will hash to the same value. In these + cases you either want to use a "struct" (value types) to make your `ParseObject`s instead of a + class (reference type) or provide your own implementation of `hash`. + + */ + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + } + + final class GameClass: 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 gameScore: GameScoreClass + var gameScores = [GameScore]() + var name = "Hello" + + //: a custom initializer + required init() { + self.gameScore = GameScoreClass() + } + + init(gameScore: GameScoreClass) { + self.gameScore = gameScore + } + + /** + Conforms to Equatable by determining if an object has the same objectId. + - note: You can specify a custom way of `Equatable` if a more detailed way is needed. + - warning: If you use the default implementation, equatable will only work if the ParseObject + has been previously synced to the parse-server (has an objectId). In addition, if two + `ParseObject`'s have the same objectId, but were modified at different times, the + default implementation will still return true. In these cases you either want to use a + "struct" (value types) to make your `ParseObject`s instead of a class (reference type) or + provide your own implementation of `==`. + - parameter lhs: first object to compare + - parameter rhs: second object to compare + + - returns: Returns a **true** if the other object has the same `objectId` or **false** if unsuccessful. + */ + public static func == (lhs: ParseObjectAsyncTests.GameClass, + rhs: ParseObjectAsyncTests.GameClass) -> Bool { + lhs.hasSameObjectId(as: rhs) + } + + /** + Conforms to `Hashable` using objectId. + - note: You can specify a custom way of `Hashable` if a more detailed way is needed. + - warning: If you use the default implementation, hash will only work if the ParseObject has been previously + synced to the parse-server (has an objectId). In addition, if two `ParseObject`'s have the same objectId, + but were modified at different times, the default implementation will hash to the same value. In these + cases you either want to use a "struct" (value types) to make your `ParseObject`s instead of a + class (reference type) or provide your own implementation of `hash`. + + */ + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + } + + struct User: ParseUser { + + //: These are required by ParseObject + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + + // These are required by ParseUser + var username: String? + var email: String? + var emailVerified: Bool? + var password: String? + var authData: [String: [String: String]?]? + + // Your custom keys + var customKey: String? + } + + struct LoginSignupResponse: ParseUser { + + var objectId: String? + var createdAt: Date? + var sessionToken: String? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + + // These are required by ParseUser + var username: String? + var email: String? + var emailVerified: Bool? + var password: String? + var authData: [String: [String: String]?]? + + // Your custom keys + var customKey: String? + + init() { + let date = Date() + self.createdAt = date + self.updatedAt = date + self.objectId = "yarr" + self.ACL = nil + self.customKey = "blah" + self.sessionToken = "myToken" + self.username = "hello10" + self.email = "hello@parse.com" + } + } + override func setUpWithError() throws { try super.setUpWithError() guard let url = URL(string: "http://localhost:1337/1") else { @@ -139,6 +344,22 @@ class ParseObjectAsyncTests: XCTestCase { // swiftlint:disable:this type_body_le try ParseStorage.shared.deleteAll() } + func loginNormally() async throws -> User { + let loginResponse = LoginSignupResponse() + + MockURLProtocol.mockRequests { _ in + do { + let encoded = try loginResponse.getEncoder().encode(loginResponse, skipKeys: .none) + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } catch { + return nil + } + } + let user = try await User.login(username: "parse", password: "user") + MockURLProtocol.removeAll() + return user + } + @MainActor func testFetch() async throws { var score = GameScore(points: 10) @@ -1250,6 +1471,375 @@ class ParseObjectAsyncTests: XCTestCase { // swiftlint:disable:this type_body_le XCTFail(error.localizedDescription) } } -} + // swiftlint:disable:next function_body_length + @MainActor + func testDeepSaveOneDeep() async throws { + let score = GameScore(points: 10) + var game = Game(gameScore: score) + + var scoreOnServer = score + scoreOnServer.createdAt = Date() + scoreOnServer.ACL = nil + scoreOnServer.objectId = "yarr" + + let response = [BatchResponseItem(success: scoreOnServer, error: nil)] + + let encoded: Data! + do { + encoded = try GameScore.getJSONEncoder().encode(response) + //Get dates in correct format from ParseDecoding strategy + let encodedScoreOnServer = try scoreOnServer.getEncoder().encode(scoreOnServer) + scoreOnServer = try scoreOnServer.getDecoder().decode(GameScore.self, from: encodedScoreOnServer) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + MockURLProtocol.mockRequests { _ in + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } + + let (savedChildren, savedChildFiles) = try await game.ensureDeepSave() + + XCTAssertEqual(savedChildren.count, 1) + XCTAssertEqual(savedChildFiles.count, 0) + var counter = 0 + var savedChildObject: PointerType? + savedChildren.forEach { (_, value) in + XCTAssertEqual(value.className, "GameScore") + XCTAssertEqual(value.objectId, "yarr") + if counter == 0 { + savedChildObject = value + } + counter += 1 + } + + guard let savedChild = savedChildObject else { + XCTFail("Should have unwrapped child object") + return + } + + // Saved updated info for game + let encodedScore: Data + do { + encodedScore = try ParseCoding.jsonEncoder().encode(savedChild) + // Decode Pointer as GameScore + game.gameScore = try game.getDecoder().decode(GameScore.self, from: encodedScore) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + // Setup ParseObject to return from mocker + MockURLProtocol.removeAll() + + var gameOnServer = game + gameOnServer.objectId = "nice" + gameOnServer.createdAt = Date() + + let encodedGamed: Data + do { + encodedGamed = try game.getEncoder().encode(gameOnServer, skipKeys: .none) + //Get dates in correct format from ParseDecoding strategy + gameOnServer = try game.getDecoder().decode(Game.self, from: encodedGamed) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + MockURLProtocol.mockRequests { _ in + return MockURLResponse(data: encodedGamed, statusCode: 200, delay: 0.0) + } + + guard let savedGame = try? game + .saveCommand() + .execute(options: [], + childObjects: savedChildren, + childFiles: savedChildFiles) else { + XCTFail("Should have saved game") + return + } + XCTAssertEqual(savedGame.objectId, gameOnServer.objectId) + XCTAssertEqual(savedGame.createdAt, gameOnServer.createdAt) + XCTAssertEqual(savedGame.updatedAt, gameOnServer.createdAt) + XCTAssertEqual(savedGame.gameScore, gameOnServer.gameScore) + } + + // swiftlint:disable:next function_body_length + @MainActor + func testDeepSaveOneDeepWithDefaultACL() async throws { + let user = try await loginNormally() + guard let userObjectId = user.objectId else { + XCTFail("Should have objectId") + return + } + let defaultACL = try ParseACL.setDefaultACL(ParseACL(), + withAccessForCurrentUser: true) + + let score = GameScore(points: 10) + var game = Game(gameScore: score) + + var scoreOnServer = score + scoreOnServer.createdAt = Date() + scoreOnServer.ACL = nil + scoreOnServer.objectId = "yarr" + + let response = [BatchResponseItem(success: scoreOnServer, error: nil)] + + let encoded: Data! + do { + encoded = try GameScore.getJSONEncoder().encode(response) + //Get dates in correct format from ParseDecoding strategy + let encodedScoreOnServer = try scoreOnServer.getEncoder().encode(scoreOnServer) + scoreOnServer = try scoreOnServer.getDecoder().decode(GameScore.self, from: encodedScoreOnServer) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + MockURLProtocol.mockRequests { _ in + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } + + let (savedChildren, savedChildFiles) = try await game.ensureDeepSave() + + XCTAssertEqual(savedChildren.count, 1) + XCTAssertEqual(savedChildFiles.count, 0) + var counter = 0 + var savedChildObject: PointerType? + savedChildren.forEach { (_, value) in + XCTAssertEqual(value.className, "GameScore") + XCTAssertEqual(value.objectId, "yarr") + if counter == 0 { + savedChildObject = value + } + counter += 1 + } + + guard let savedChild = savedChildObject else { + XCTFail("Should have unwrapped child object") + return + } + + // Saved updated info for game + let encodedScore: Data + do { + encodedScore = try ParseCoding.jsonEncoder().encode(savedChild) + // Decode Pointer as GameScore + game.gameScore = try game.getDecoder().decode(GameScore.self, from: encodedScore) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + //Setup ParseObject to return from mocker + MockURLProtocol.removeAll() + + var gameOnServer = game + gameOnServer.objectId = "nice" + gameOnServer.createdAt = Date() + + let encodedGamed: Data + do { + encodedGamed = try game.getEncoder().encode(gameOnServer, skipKeys: .none) + //Get dates in correct format from ParseDecoding strategy + gameOnServer = try game.getDecoder().decode(Game.self, from: encodedGamed) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + MockURLProtocol.mockRequests { _ in + return MockURLResponse(data: encodedGamed, statusCode: 200, delay: 0.0) + } + + guard let savedGame = try? game + .saveCommand() + .execute(options: [], + childObjects: savedChildren, + childFiles: savedChildFiles) else { + XCTFail("Should have saved game") + return + } + XCTAssertEqual(savedGame.objectId, gameOnServer.objectId) + XCTAssertEqual(savedGame.createdAt, gameOnServer.createdAt) + XCTAssertEqual(savedGame.updatedAt, gameOnServer.createdAt) + XCTAssertEqual(savedGame.gameScore, gameOnServer.gameScore) + XCTAssertNotNil(savedGame.ACL) + XCTAssertEqual(savedGame.ACL?.publicRead, defaultACL.publicRead) + XCTAssertEqual(savedGame.ACL?.publicWrite, defaultACL.publicWrite) + XCTAssertTrue(defaultACL.getReadAccess(objectId: userObjectId)) + XCTAssertTrue(defaultACL.getWriteAccess(objectId: userObjectId)) + } + + @MainActor + func testDeepSaveDetectCircular() async throws { + let score = GameScoreClass(points: 10) + let game = GameClass(gameScore: score) + game.objectId = "nice" + score.game = game + do { + _ = try await game.ensureDeepSave() + XCTFail("Should have thrown error") + } catch { + guard let parseError = error as? ParseError else { + XCTFail("Should have failed with an error of detecting a circular dependency") + return + } + XCTAssertTrue(parseError.message.contains("circular")) + } + } + + @MainActor + func testAllowFieldsWithSameObject() async throws { + var score = GameScore(points: 10) + var level = Level() + level.objectId = "nice" + score.level = level + score.nextLevel = level + do { + _ = try await score.ensureDeepSave() + } catch { + XCTFail("Should not throw an error: \(error.localizedDescription)") + } + } + + @MainActor + func testDeepSaveTwoDeep() async throws { + var score = GameScore(points: 10) + score.level = Level() + var game = Game(gameScore: score) + game.objectId = "nice" + + var levelOnServer = score + levelOnServer.createdAt = Date() + levelOnServer.ACL = nil + levelOnServer.objectId = "yarr" + let pointer = try levelOnServer.toPointer() + + let response = [BatchResponseItem>(success: pointer, error: nil)] + let encoded: Data! + do { + encoded = try ParseCoding.jsonEncoder().encode(response) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + MockURLProtocol.mockRequests { _ in + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } + + let (savedChildren, savedChildFiles) = try await game.ensureDeepSave() + XCTAssertEqual(savedChildFiles.count, 0) + XCTAssertEqual(savedChildren.count, 2) + let gameScore = savedChildren.compactMap { (_, value) -> PointerType? in + if value.className == "GameScore" { + return value + } else { + return nil + } + } + XCTAssertEqual(gameScore.count, 1) + XCTAssertEqual(gameScore.first?.className, "GameScore") + XCTAssertEqual(gameScore.first?.objectId, "yarr") + + let level = savedChildren.compactMap { (_, value) -> PointerType? in + if value.className == "Level" { + return value + } else { + return nil + } + } + XCTAssertEqual(level.count, 1) + XCTAssertEqual(level.first?.className, "Level") + XCTAssertEqual(level.first?.objectId, "yarr") // This is because mocker is only returning 1 response + } + + #if !os(Linux) && !os(Android) && !os(Windows) + // swiftlint:disable:next function_body_length + @MainActor + func testDeepSaveObjectWithFile() async throws { + var game = Game2() + + guard let cloudPath = URL(string: "https://parseplatform.org/img/logo.svg"), + // swiftlint:disable:next line_length + let parseURL = URL(string: "http://localhost:1337/1/files/applicationId/89d74fcfa4faa5561799e5076593f67c_logo.svg") else { + XCTFail("Should create URL") + return + } + + let parseFile = ParseFile(name: "profile.svg", cloudURL: cloudPath) + game.profilePicture = parseFile + + let fileResponse = FileUploadResponse(name: "89d74fcfa4faa5561799e5076593f67c_\(parseFile.name)", + url: parseURL) + + let encoded: Data! + do { + encoded = try ParseCoding.jsonEncoder().encode(fileResponse) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + MockURLProtocol.mockRequests { _ in + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } + + let (savedChildren, savedChildFiles) = try await game.ensureDeepSave() + XCTAssertEqual(savedChildren.count, 0) + XCTAssertEqual(savedChildFiles.count, 1) + var counter = 0 + var savedFile: ParseFile? + savedChildFiles.forEach { (_, value) in + XCTAssertEqual(value.url, fileResponse.url) + XCTAssertEqual(value.name, fileResponse.name) + if counter == 0 { + savedFile = value + } + counter += 1 + } + + //Saved updated info for game + game.profilePicture = savedFile + + //Setup ParseObject to return from mocker + MockURLProtocol.removeAll() + + var gameOnServer = game + gameOnServer.objectId = "nice" + gameOnServer.createdAt = Date() + gameOnServer.profilePicture = savedFile + + let encodedGamed: Data + do { + encodedGamed = try game.getEncoder().encode(gameOnServer, skipKeys: .none) + //Get dates in correct format from ParseDecoding strategy + gameOnServer = try game.getDecoder().decode(Game2.self, from: encodedGamed) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + MockURLProtocol.mockRequests { _ in + return MockURLResponse(data: encodedGamed, statusCode: 200, delay: 0.0) + } + + guard let savedGame = try? game + .saveCommand() + .execute(options: [], + childObjects: savedChildren, + childFiles: savedChildFiles) else { + XCTFail("Should have saved game") + return + } + XCTAssertEqual(savedGame.objectId, gameOnServer.objectId) + XCTAssertEqual(savedGame.createdAt, gameOnServer.createdAt) + XCTAssertEqual(savedGame.updatedAt, gameOnServer.createdAt) + XCTAssertEqual(savedGame.profilePicture, gameOnServer.profilePicture) + } + #endif +} #endif diff --git a/Tests/ParseSwiftTests/ParsePointerAsyncTests.swift b/Tests/ParseSwiftTests/ParsePointerAsyncTests.swift index d44225e62..dfe39f204 100644 --- a/Tests/ParseSwiftTests/ParsePointerAsyncTests.swift +++ b/Tests/ParseSwiftTests/ParsePointerAsyncTests.swift @@ -26,6 +26,8 @@ class ParsePointerAsyncTests: XCTestCase { // swiftlint:disable:this type_body_l //: Your own properties var points: Int + var other: Pointer? + var others: [Pointer]? //: a custom initializer init() { @@ -57,6 +59,7 @@ class ParsePointerAsyncTests: XCTestCase { // swiftlint:disable:this type_body_l try ParseStorage.shared.deleteAll() } + @MainActor func testFetch() async throws { var score = GameScore(points: 10) let objectId = "yarr" @@ -100,5 +103,42 @@ class ParsePointerAsyncTests: XCTestCase { // swiftlint:disable:this type_body_l XCTFail(error.localizedDescription) } } + + @MainActor + func testDetectCircularDependency() async throws { + var score = GameScore(points: 10) + score.objectId = "nice" + score.other = try score.toPointer() + + do { + _ = try await score.ensureDeepSave() + XCTFail("Should have thrown error") + } catch { + guard let parseError = error as? ParseError else { + XCTFail("Should have failed with an error of detecting a circular dependency") + return + } + XCTAssertTrue(parseError.message.contains("circular")) + } + } + + @MainActor + func testDetectCircularDependencyArray() async throws { + var score = GameScore(points: 10) + score.objectId = "nice" + let first = try score.toPointer() + score.others = [first, first] + + do { + _ = try await score.ensureDeepSave() + XCTFail("Should have thrown error") + } catch { + guard let parseError = error as? ParseError else { + XCTFail("Should have failed with an error of detecting a circular dependency") + return + } + XCTAssertTrue(parseError.message.contains("circular")) + } + } } #endif