diff --git a/Amplify.xcodeproj/project.pbxproj b/Amplify.xcodeproj/project.pbxproj index 18a6c9d5ff..92a32e4658 100644 --- a/Amplify.xcodeproj/project.pbxproj +++ b/Amplify.xcodeproj/project.pbxproj @@ -49,6 +49,8 @@ 212CE70F23E9E991007D8E71 /* ModelDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212CE70A23E9E991007D8E71 /* ModelDecorator.swift */; }; 212CE71123E9EA6A007D8E71 /* ModelField+GraphQL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212CE71023E9EA6A007D8E71 /* ModelField+GraphQL.swift */; }; 212CE71323E9F2ED007D8E71 /* DirectiveNameDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212CE71223E9F2ED007D8E71 /* DirectiveNameDecorator.swift */; }; + 213481D0242A6040001966DE /* AnyQueryPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 213481CF242A603F001966DE /* AnyQueryPredicate.swift */; }; + 213481D2242A63AA001966DE /* QueryOperator+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 213481D1242A63AA001966DE /* QueryOperator+Codable.swift */; }; 21409C552384C55D000A53C9 /* LabelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21409C542384C55D000A53C9 /* LabelType.swift */; }; 21409C5A2384C57D000A53C9 /* GraphQLMutationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21409C572384C57D000A53C9 /* GraphQLMutationType.swift */; }; 21409C5B2384C57D000A53C9 /* GraphQLQueryType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21409C582384C57D000A53C9 /* GraphQLQueryType.swift */; }; @@ -579,6 +581,8 @@ 212CE71B23EA1847007D8E71 /* GraphQLListQueryTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphQLListQueryTests.swift; sourceTree = ""; }; 212CE71C23EA1847007D8E71 /* GraphQLSyncQueryTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphQLSyncQueryTests.swift; sourceTree = ""; }; 212CE72023EA184F007D8E71 /* GraphQLSubscriptionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphQLSubscriptionTests.swift; sourceTree = ""; }; + 213481CF242A603F001966DE /* AnyQueryPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyQueryPredicate.swift; sourceTree = ""; }; + 213481D1242A63AA001966DE /* QueryOperator+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryOperator+Codable.swift"; sourceTree = ""; }; 21409C4C23847E41000A53C9 /* LabelType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelType.swift; sourceTree = ""; }; 21409C542384C55D000A53C9 /* LabelType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelType.swift; sourceTree = ""; }; 21409C572384C57D000A53C9 /* GraphQLMutationType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphQLMutationType.swift; sourceTree = ""; }; @@ -1792,9 +1796,11 @@ B98E9D042372236200934B51 /* Query */ = { isa = PBXGroup; children = ( + 213481CF242A603F001966DE /* AnyQueryPredicate.swift */, B98E9D062372236200934B51 /* ModelKey.swift */, B98E9D0A2372236200934B51 /* QueryField.swift */, B98E9D072372236200934B51 /* QueryOperator.swift */, + 213481D1242A63AA001966DE /* QueryOperator+Codable.swift */, B98E9D0B2372236200934B51 /* QueryOperator+Equatable.swift */, B98E9D082372236200934B51 /* QueryPredicate.swift */, B98E9D0C2372236200934B51 /* QueryPredicate+Equatable.swift */, @@ -3703,6 +3709,7 @@ FAA2E8BE23A00BD600E420EA /* AmplifyAPICategory.swift in Sources */, 210DBC142332B3C6009B9E51 /* StorageGetURLOperation.swift in Sources */, 95DAAB2A237E63370028544F /* Pose.swift in Sources */, + 213481D0242A6040001966DE /* AnyQueryPredicate.swift in Sources */, FA09B94D2322CC05000E064D /* StorageCategoryConfiguration.swift in Sources */, 950A26DF23D15D9800D92B19 /* SpeechToTextResult.swift in Sources */, B92E03AF2367CE7A006CEB8D /* DataStoreCategoryBehavior.swift in Sources */, @@ -3744,6 +3751,7 @@ 95DAAB31237E63370028544F /* PartOfSpeech.swift in Sources */, B9FAA13A238BBADE009414B4 /* List+Combine.swift in Sources */, FA8EE77B2386271A0097E4F1 /* Model+AnyModel.swift in Sources */, + 213481D2242A63AA001966DE /* QueryOperator+Codable.swift in Sources */, FA56F72522B14B6A0039754A /* Resumable.swift in Sources */, FAA2E8C023A00C6500E420EA /* AmplifyAPICategory+APICategory.swift in Sources */, 21FFF997230C96CB005878EA /* StorageRemoveOperation.swift in Sources */, diff --git a/Amplify/Categories/DataStore/Query/AnyQueryPredicate.swift b/Amplify/Categories/DataStore/Query/AnyQueryPredicate.swift new file mode 100644 index 0000000000..61b245b277 --- /dev/null +++ b/Amplify/Categories/DataStore/Query/AnyQueryPredicate.swift @@ -0,0 +1,89 @@ +// +// Copyright 2018-2020 Amazon.com, +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Type-erased wrapper of for encoding and decoding `QueryPredicate` +struct AnyQueryPredicate: Codable { + var base: QueryPredicate + init(_ base: QueryPredicate) { + self.base = base + } + + private enum CodingKeys: CodingKey { + case type, base + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(QueryPredicateType.self, forKey: .type) + self.base = try type.metatype.init(from: decoder) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type(of: base).type, forKey: .type) + try base.encode(to: encoder) + } +} + +/// Adds JSON serialization behavior for `AnyQueryPredicate` +extension AnyQueryPredicate { + /// Converts the `AnyQueryPredicate` instance to a JSON object as `String`. + /// - Parameters: + /// - encoder: an optional JSONEncoder to use to encode the model. Defaults to `JSONEncoder()`, using a + /// custom date formatter that encodes ISO8601 dates with fractional seconds + /// - Returns: the JSON representation of the `Model` + /// - seealso: https://developer.apple.com/documentation/foundation/jsonencoder/2895034-encode + public func toJSON(encoder: JSONEncoder? = nil) throws -> String { + let resolvedEncoder: JSONEncoder + if let encoder = encoder { + resolvedEncoder = encoder + } else { + resolvedEncoder = JSONEncoder(dateEncodingStrategy: ModelDateFormatting.encodingStrategy) + } + + let data = try resolvedEncoder.encode(self) + guard let json = String(data: data, encoding: .utf8) else { + throw DataStoreError.decodingError( + "Invalid UTF-8 Data object. Could not convert the encoded Model into a valid UTF-8 JSON string", + "Check if your QueryPredicate doesn't contain any value with invalid UTF-8 characters." + ) + } + + return json + } + + /// De-serialize a JSON string into an instance of the concrete type that conforms + /// to the `AnyQueryPredicate` struct. + /// + /// - Parameters: + /// - json: a valid JSON object as `String` + /// - decoder: an optional JSONDecoder to use to decode the model. Defaults to `JSONDecoder()`, using a + /// custom date formatter that decodes ISO8601 dates both with and without fractional seconds + /// - Returns: an instance of the concrete type conforming to `Model` + /// - Throws: `DecodingError.dataCorrupted` in case data is not a valid JSON or any + /// other decoding specific error that `JSONDecoder.decode()` might throw. + public static func from(json: String, + decoder: JSONDecoder? = nil) throws -> Self { + let resolvedDecoder: JSONDecoder + if let decoder = decoder { + resolvedDecoder = decoder + } else { + resolvedDecoder = JSONDecoder(dateDecodingStrategy: ModelDateFormatting.decodingStrategy) + } + + guard let data = json.data(using: .utf8) else { + throw DataStoreError.decodingError( + "Invalid JSON string. Could not convert the passed JSON string into a UTF-8 Data object", + "Ensure the JSON doesn't contain any invalid UTF-8 data:\n\n\(json)" + ) + } + + return try resolvedDecoder.decode(Self.self, from: data) + } +} diff --git a/Amplify/Categories/DataStore/Query/QueryOperator+Codable.swift b/Amplify/Categories/DataStore/Query/QueryOperator+Codable.swift new file mode 100644 index 0000000000..921ef17bb8 --- /dev/null +++ b/Amplify/Categories/DataStore/Query/QueryOperator+Codable.swift @@ -0,0 +1,176 @@ +// +// Copyright 2018-2020 Amazon.com, +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Custom decoder/encoder to store and retrieve a `QueryOperator` +extension QueryOperator: Codable { + + private enum CodingKeys: String, CodingKey { + /// QueryOperator type + case base + + /// Core types that conform to the `Persistable` protocol, including nil case + case persistableType + + /// Value with type specified by `.persistableType` + case firstValue + + /// Additional value with type specified by `.persistableType` + case secondValue + } + private enum Base: String, Codable { + case notEqual + case equals + case lessOrEqual + case lessThan + case greaterOrEqual + case greaterThan + case contains + case between + case beginsWith + } + private enum PersistableType: String, Codable { + case bool + case date + case double + case int + case string + case null + } + + // swiftlint:disable cyclomatic_complexity + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let queryOperator = try container.decode(Base.self, forKey: .base) + let persistableType = try container.decode(PersistableType.self, forKey: .persistableType) + let value: Persistable? + switch persistableType { + case .bool: + value = try container.decode(Bool.self, forKey: .firstValue) + case .date: + value = try container.decode(Date.self, forKey: .firstValue) + case .double: + value = try container.decode(Double.self, forKey: .firstValue) + case .int: + value = try container.decode(Int.self, forKey: .firstValue) + case .string: + value = try container.decode(String.self, forKey: .firstValue) + case .null: + value = nil + } + switch queryOperator { + case .notEqual: + self = .notEqual(value) + return + case .equals: + self = .equals(value) + return + case .lessOrEqual: + if let value = value { + self = .lessOrEqual(value) + return + } + case .lessThan: + if let value = value { + self = .lessThan(value) + return + } + case .greaterOrEqual: + if let value = value { + self = .greaterOrEqual(value) + return + } + case .greaterThan: + if let value = value { + self = .greaterThan(value) + return + } + case .contains: + if let value = value as? String { + self = .contains(value) + return + } + case .between: + if let value = value { + let secondValue = try container.decode(String.self, forKey: .secondValue) + self = .between(start: value, end: secondValue) + return + } + case .beginsWith: + if let value = value as? String { + self = .beginsWith(value) + return + } + } + + throw DataStoreError.decodingError("Error decoding QueryOperator", + "Make sure the conforming types are correct for the QueryOperator used.") + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .notEqual(let value): + try encodePersistable(encoder, queryOperator: Base.notEqual, firstValue: value) + case .equals(let value): + try encodePersistable(encoder, queryOperator: Base.equals, firstValue: value) + case .lessOrEqual(let value): + try encodePersistable(encoder, queryOperator: Base.lessOrEqual, firstValue: value) + case .lessThan(let value): + try encodePersistable(encoder, queryOperator: Base.lessOrEqual, firstValue: value) + case .greaterOrEqual(let value): + try encodePersistable(encoder, queryOperator: Base.greaterOrEqual, firstValue: value) + case .greaterThan(let value): + try encodePersistable(encoder, queryOperator: Base.greaterThan, firstValue: value) + case .contains(let value): + try encodePersistable(encoder, queryOperator: Base.contains, firstValue: value) + case .between(let start, let end): + try encodePersistable(encoder, queryOperator: Base.between, firstValue: start, secondValue: end) + case .beginsWith(let value): + try encodePersistable(encoder, queryOperator: Base.beginsWith, firstValue: value) + } + } + + private func encodePersistable(_ encoder: Encoder, + queryOperator: Base, + firstValue: Persistable?, + secondValue: Persistable? = nil) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(queryOperator, forKey: .base) + + if let value = firstValue as? Bool { + try container.encode(PersistableType.bool, forKey: .persistableType) + try container.encode(value, forKey: .firstValue) + } else if let value = firstValue as? Date { + try container.encode(PersistableType.date, forKey: .persistableType) + try container.encode(value, forKey: .firstValue) + } else if let value = firstValue as? Double { + try container.encode(PersistableType.double, forKey: .persistableType) + try container.encode(value, forKey: .firstValue) + } else if let value = firstValue as? Int { + try container.encode(PersistableType.int, forKey: .persistableType) + try container.encode(value, forKey: .firstValue) + } else if let value = firstValue as? String { + try container.encode(PersistableType.string, forKey: .persistableType) + try container.encode(value, forKey: .firstValue) + } else { + try container.encode(PersistableType.null, forKey: .persistableType) + } + + if let secondValue = secondValue as? Bool { + try container.encode(secondValue, forKey: .secondValue) + } else if let secondValue = secondValue as? Date { + try container.encode(secondValue, forKey: .secondValue) + } else if let secondValue = secondValue as? Double { + try container.encode(secondValue, forKey: .secondValue) + } else if let secondValue = secondValue as? Int { + try container.encode(secondValue, forKey: .secondValue) + } else if let secondValue = secondValue as? String { + try container.encode(secondValue, forKey: .secondValue) + } + } +} diff --git a/Amplify/Categories/DataStore/Query/QueryPredicate.swift b/Amplify/Categories/DataStore/Query/QueryPredicate.swift index db3f4af74e..3feda3d9ef 100644 --- a/Amplify/Categories/DataStore/Query/QueryPredicate.swift +++ b/Amplify/Categories/DataStore/Query/QueryPredicate.swift @@ -8,9 +8,29 @@ import Foundation /// Protocol that indicates concrete types conforming to it can be used a predicate member. -public protocol QueryPredicate {} +public protocol QueryPredicate: Codable { + static var type: QueryPredicateType { get } +} -public enum QueryPredicateGroupType: String { +/// List of possible `QueryPredicate` types +public enum QueryPredicateType: String, Codable { + case group + case constant + case operation + + var metatype: QueryPredicate.Type { + switch self { + case .group: + return QueryPredicateGroup.self + case .constant: + return QueryPredicateConstant.self + case .operation: + return QueryPredicateOperation.self + } + } +} + +public enum QueryPredicateGroupType: String, Codable { case and case or case not @@ -26,11 +46,14 @@ public func not(_ predicate: Predicate) -> QueryPredi /// The case `.all` is a predicate used as an argument to select all of a single modeltype. We /// chose `.all` instead of `nil` because we didn't want to use the implicit nature of `nil` to /// specify an action applies to an entire data set. -public enum QueryPredicateConstant: QueryPredicate { +public enum QueryPredicateConstant: String, QueryPredicate { + public static var type: QueryPredicateType = .constant + case all } public class QueryPredicateGroup: QueryPredicate { + public static var type: QueryPredicateType = .group public internal(set) var type: QueryPredicateGroupType public internal(set) var predicates: [QueryPredicate] @@ -72,9 +95,32 @@ public class QueryPredicateGroup: QueryPredicate { public static prefix func ! (rhs: QueryPredicateGroup) -> QueryPredicateGroup { return not(rhs) } + + /// Provide conformance to `Codable` + + enum CodingKeys: String, CodingKey { + case type + case predicates + } + + /// Decode `type` and `predicates`. Array of predicates are first decoded using `AnyQueryPredicate` wrapper, and + /// then the inner `QueryPredicate` is retrieved. + required public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(QueryPredicateGroupType.self, forKey: .type) + self.predicates = try container.decode([AnyQueryPredicate].self, forKey: .predicates).map { $0.base } + } + + /// Encode `type` and `predicates`. Array of predicates are encoded as an array of `AnyQueryPredicate`'s. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(predicates.map(AnyQueryPredicate.init), forKey: .predicates) + } } public class QueryPredicateOperation: QueryPredicate { + public static var type: QueryPredicateType = .operation public let field: String public let `operator`: QueryOperator diff --git a/Amplify/Categories/DataStore/Subscribe/MutationEvent+Schema.swift b/Amplify/Categories/DataStore/Subscribe/MutationEvent+Schema.swift index 0153c1ef0e..0ad2e9a8b0 100644 --- a/Amplify/Categories/DataStore/Subscribe/MutationEvent+Schema.swift +++ b/Amplify/Categories/DataStore/Subscribe/MutationEvent+Schema.swift @@ -17,6 +17,7 @@ extension MutationEvent { case createdAt case version case inProcess + case queryPredicateJson } public static let keys = CodingKeys.self @@ -37,7 +38,8 @@ extension MutationEvent { .field(mutation.mutationType, is: .required, ofType: .string), .field(mutation.createdAt, is: .required, ofType: .dateTime), .field(mutation.version, is: .optional, ofType: .int), - .field(mutation.inProcess, is: .optional, ofType: .bool) + .field(mutation.inProcess, is: .optional, ofType: .bool), + .field(mutation.queryPredicateJson, is: .optional, ofType: .string) ) } } diff --git a/Amplify/Categories/DataStore/Subscribe/MutationEvent.swift b/Amplify/Categories/DataStore/Subscribe/MutationEvent.swift index 6685069344..d040e82d09 100644 --- a/Amplify/Categories/DataStore/Subscribe/MutationEvent.swift +++ b/Amplify/Categories/DataStore/Subscribe/MutationEvent.swift @@ -16,6 +16,7 @@ public struct MutationEvent: Model { public var createdAt: Date public var version: Int? public var inProcess: Bool + public var queryPredicateJson: String? public init(id: Identifier = UUID().uuidString, modelId: String, @@ -24,7 +25,8 @@ public struct MutationEvent: Model { mutationType: MutationType, createdAt: Date = Date(), version: Int? = nil, - inProcess: Bool = false) { + inProcess: Bool = false, + queryPredicateJson: String? = nil) { self.id = id self.modelId = modelId self.modelName = modelName @@ -33,18 +35,31 @@ public struct MutationEvent: Model { self.createdAt = createdAt self.version = version self.inProcess = inProcess + self.queryPredicateJson = queryPredicateJson } public init(model: M, mutationType: MutationType, - version: Int? = nil) throws { + version: Int? = nil, + queryPredicate: QueryPredicate? = nil) throws { let modelType = type(of: model) let json = try model.toJSON() - self.init(modelId: model.id, - modelName: modelType.schema.name, - json: json, - mutationType: mutationType, - version: version) + if let queryPredicate = queryPredicate { + let anyQueryPredicate = AnyQueryPredicate(queryPredicate) + self.init(modelId: model.id, + modelName: modelType.schema.name, + json: json, + mutationType: mutationType, + version: version, + queryPredicateJson: try anyQueryPredicate.toJSON()) + } else { + self.init(modelId: model.id, + modelName: modelType.schema.name, + json: json, + mutationType: mutationType, + version: version) + } + } public func decodeModel() throws -> Model { @@ -69,4 +84,13 @@ public struct MutationEvent: Model { return typedModel } + + // Decodes the query predicate from the mutation event + public func decodeQueryPredicate() throws -> QueryPredicate? { + if let queryPredicateJson = queryPredicateJson { + return try AnyQueryPredicate.from(json: queryPredicateJson).base + } else { + return nil + } + } } diff --git a/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift b/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift index 2ab51d1365..fa2fc26366 100644 --- a/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift +++ b/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift @@ -23,7 +23,7 @@ extension GraphQLRequest { public static func updateMutation(of model: Model, where predicate: QueryPredicate? = nil, version: Int? = nil) -> GraphQLRequest { - createOrUpdateMutation(of: model, type: .update, version: version) + createOrUpdateMutation(of: model, where: predicate, type: .update, version: version) } public static func deleteMutation(modelName: String, diff --git a/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLDocument/GraphQLSyncQueryTests.swift b/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLDocument/GraphQLSyncQueryTests.swift index ef60f761d1..642ed4e776 100644 --- a/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLDocument/GraphQLSyncQueryTests.swift +++ b/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLDocument/GraphQLSyncQueryTests.swift @@ -113,5 +113,4 @@ class GraphQLSyncQueryTests: XCTestCase { """ XCTAssertEqual(document.stringValue, expectedQueryDocument) } - } diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Storage/StorageEngine.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Storage/StorageEngine.swift index a8d230baa2..8a6daac8c8 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Storage/StorageEngine.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Storage/StorageEngine.swift @@ -119,6 +119,12 @@ final class StorageEngine: StorageEngineBehavior { let mutationType = modelExists ? MutationEvent.MutationType.update : .create + // TODO: finalize save behavior + if mutationType == .create && condition != nil { + let dataStoreError = DataStoreError.invalidOperation(causedBy: nil) + completion(.failure(causedBy: dataStoreError)) + } + let wrappedCompletion: DataStoreCallback = { result in guard type(of: model).schema.isSyncable, let syncEngine = self.syncEngine else { completion(result) @@ -134,6 +140,7 @@ final class StorageEngine: StorageEngineBehavior { self.log.verbose("\(#function) syncing mutation for \(savedModel)") self.syncMutation(of: savedModel, mutationType: mutationType, + queryPredicate: condition, syncEngine: syncEngine, completion: completion) } else { @@ -372,11 +379,14 @@ final class StorageEngine: StorageEngineBehavior { @available(iOS 13.0, *) private func syncMutation(of savedModel: M, mutationType: MutationEvent.MutationType, + queryPredicate: QueryPredicate? = nil, syncEngine: RemoteSyncEngineBehavior, completion: @escaping DataStoreCallback) { let mutationEvent: MutationEvent do { - mutationEvent = try MutationEvent(model: savedModel, mutationType: mutationType) + mutationEvent = try MutationEvent(model: savedModel, + mutationType: mutationType, + queryPredicate: queryPredicate) } catch { let dataStoreError = DataStoreError(error: error) completion(.failure(dataStoreError)) diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift index 96b3fe6d73..7b7ec8da6c 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift @@ -73,10 +73,9 @@ extension AWSMutationDatabaseAdapter: MutationEventIngester { return .saveCandidate } - // TODO: Handle conditional mutations, something like: - // if candidate.isConditional { - // return MutationDisposition.saveCandidate - // } + if candidate.queryPredicateJson != nil { + return .saveCandidate + } guard let candidateMutationType = GraphQLMutationType(rawValue: candidate.mutationType) else { let dataStoreError = diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift index 190f5abd40..d6ae7065c8 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift @@ -94,12 +94,17 @@ class SyncMutationToCloudOperation: Operation { do { switch mutationType { case .delete: + let queryPredicate = try mutationEvent.decodeQueryPredicate() request = GraphQLRequest.deleteMutation(modelName: mutationEvent.modelName, id: mutationEvent.modelId, + where: queryPredicate, version: mutationEvent.version) case .update: + let queryPredicate = try mutationEvent.decodeQueryPredicate() let model = try mutationEvent.decodeModel() - request = GraphQLRequest.updateMutation(of: model, version: mutationEvent.version) + request = GraphQLRequest.updateMutation(of: model, + where: queryPredicate, + version: mutationEvent.version) case .create: let model = try mutationEvent.decodeModel() request = GraphQLRequest.createMutation(of: model, version: mutationEvent.version) diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift index 2383b48a8a..43c6a47233 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift @@ -82,4 +82,65 @@ class DataStoreEndToEndTests: SyncEngineIntegrationTestBase { wait(for: [deleteReceived], timeout: networkTimeout) } + + /// - Given: A post that has been saved + /// - When: + /// - attempt to update the existing post with a condition that matches existing data + /// - Then: + /// - the update with condition that matches existing data will be applied and returned. + func testCreateThenMutateWithCondition() throws { + try startAmplifyAndWaitForSync() + + let post = Post.keys + let date = Date() + let title = "This is a new post I created" + let newPost = Post( + title: title, + content: "Original content from DataStoreEndToEndTests at \(date)", + createdAt: date) + + var updatedPost = newPost + updatedPost.content = "UPDATED CONTENT from DataStoreEndToEndTests at \(Date())" + + let createReceived = expectation(description: "Create notification received") + let updateReceived = expectation(description: "Update notification received") + + let hubListener = Amplify.Hub.listen( + to: .dataStore, + eventName: HubPayload.EventName.DataStore.syncReceived) { payload in + guard let mutationEvent = payload.data as? MutationEvent, + let post = try? mutationEvent.decodeModel() as? Post + else { + XCTFail("Can't cast payload as mutation event") + return + } + + if mutationEvent.mutationType == GraphQLMutationType.create.rawValue { + XCTAssertEqual(post.content, post.content) + XCTAssertEqual(mutationEvent.version, 1) + createReceived.fulfill() + return + } + + if mutationEvent.mutationType == GraphQLMutationType.update.rawValue { + XCTAssertEqual(post.content, updatedPost.content) + XCTAssertEqual(mutationEvent.version, 2) + updateReceived.fulfill() + return + } + } + + guard try HubListenerTestUtilities.waitForListener(with: hubListener, timeout: 5.0) else { + XCTFail("Listener not registered for hub") + return + } + + Amplify.DataStore.save(newPost) { _ in } + + wait(for: [createReceived], timeout: networkTimeout) + + Amplify.DataStore.save(updatedPost, where: post.title == title) { _ in } + + wait(for: [updateReceived], timeout: networkTimeout) + } } diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/TestSupport/SyncEngineIntegrationTestBase.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/TestSupport/SyncEngineIntegrationTestBase.swift index 3331078775..ba90ed360a 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/TestSupport/SyncEngineIntegrationTestBase.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/TestSupport/SyncEngineIntegrationTestBase.swift @@ -41,7 +41,7 @@ class SyncEngineIntegrationTestBase: XCTestCase { Amplify.Logging.logLevel = .verbose do { - try Amplify.add(plugin: AWSAPIPlugin()) + try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: TestModelRegistration())) try Amplify.add(plugin: AWSDataStorePlugin(modelRegistration: TestModelRegistration())) } catch { XCTFail(String(describing: error)) diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/AWSMutationDatabaseAdapterTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/AWSMutationDatabaseAdapterTests.swift index 0238c415e5..9dbf552878 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/AWSMutationDatabaseAdapterTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/AWSMutationDatabaseAdapterTests.swift @@ -16,6 +16,7 @@ class AWSMutationDatabaseAdapterTests: XCTestCase { var databaseAdapter: AWSMutationDatabaseAdapter! let model1 = Post(title: "model1", content: "content1", createdAt: Date()) + let post = Post.keys override func setUp() { do { @@ -35,6 +36,26 @@ class AWSMutationDatabaseAdapterTests: XCTestCase { XCTAssertEqual(disposition, .replaceLocalWithCandidate) } + func test_saveCandidate_CanadidateUpdateWithCondition() throws { + let anyLocal = try MutationEvent(model: model1, mutationType: MutationEvent.MutationType.create) + let candidateUpdate = try MutationEvent(model: model1, + mutationType: MutationEvent.MutationType.update, + queryPredicate: post.title == model1.title) + + let disposition = databaseAdapter.disposition(for: candidateUpdate, given: [anyLocal]) + XCTAssertEqual(disposition, .saveCandidate) + } + + func test_saveCandidate_CanadidateDeleteWithCondition() throws { + let anyLocal = try MutationEvent(model: model1, mutationType: MutationEvent.MutationType.create) + let candidateUpdate = try MutationEvent(model: model1, + mutationType: MutationEvent.MutationType.delete, + queryPredicate: post.title == model1.title) + + let disposition = databaseAdapter.disposition(for: candidateUpdate, given: [anyLocal]) + XCTAssertEqual(disposition, .saveCandidate) + } + func test_replaceLocal_BothUpdate() throws { let localCreate = try MutationEvent(model: model1, mutationType: MutationEvent.MutationType.update) let candidateUpdate = try MutationEvent(model: model1, mutationType: MutationEvent.MutationType.update) diff --git a/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj b/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj index e8565f5ebe..2e5a7634bf 100644 --- a/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj +++ b/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj @@ -944,6 +944,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2134813E241AF365001966DE /* amplifyconfiguration.json in Resources */, 2149E61423886C7F00873955 /* LaunchScreen.storyboard in Resources */, 2149E61123886C7F00873955 /* Assets.xcassets in Resources */, 2149E60F23886C7B00873955 /* Main.storyboard in Resources */,