From 8474944aee617757fb3f28ff397c4cba5aa52342 Mon Sep 17 00:00:00 2001 From: Michael Law <1365977+lawmicha@users.noreply.github.com> Date: Thu, 18 Aug 2022 19:51:41 -0400 Subject: [PATCH 1/8] feat: LazyModel with AppSyncModelProvider --- .../API/Internal/APICategory+Resettable.swift | 1 + .../Collection/ArrayLiteralListProvider.swift | 19 + .../Model/Collection/List+Model.swift | 98 +++ .../Model/Internal/ModelListDecoder.swift | 24 + .../Model/Internal/ModelListProvider.swift | 11 +- Amplify/Core/Error/CoreError.swift | 5 + .../Sources/AWSAPIPlugin/AWSAPIPlugin.swift | 1 + .../Core/AppSyncListDecoder.swift | 38 + .../Core/AppSyncListPayload.swift | 17 + .../Core/AppSyncListProvider.swift | 87 ++- .../Core/AppSyncListResponse.swift | 33 + .../Core/AppSyncModelMetadata.swift | 53 ++ .../GraphQLResponseDecoder+DecodeData.swift | 36 +- .../Utils/GraphQLRequest+toListQuery.swift | 19 + ...sponseDecoderLazyPostComment4V2Tests.swift | 687 ++++++++++++++++++ ...QLResponseDecoderPostComment4V2Tests.swift | 502 +++++++++++++ .../Decode/GraphQLResponseDecoderTests.swift | 3 + .../GraphQLRequest/GraphQLRequest+Model.swift | 36 +- .../README.md | 2 +- 19 files changed, 1627 insertions(+), 45 deletions(-) create mode 100644 AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift create mode 100644 AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift diff --git a/Amplify/Categories/API/Internal/APICategory+Resettable.swift b/Amplify/Categories/API/Internal/APICategory+Resettable.swift index 547c2585f9..a5448f16ba 100644 --- a/Amplify/Categories/API/Internal/APICategory+Resettable.swift +++ b/Amplify/Categories/API/Internal/APICategory+Resettable.swift @@ -23,6 +23,7 @@ extension APICategory: Resettable { log.verbose("Resetting ModelRegistry and ModelListDecoderRegistry") ModelRegistry.reset() ModelListDecoderRegistry.reset() + ModelProviderRegistry.reset() log.verbose("Resetting ModelRegistry and ModelListDecoderRegistry: finished") isConfigured = false diff --git a/Amplify/Categories/DataStore/Model/Collection/ArrayLiteralListProvider.swift b/Amplify/Categories/DataStore/Model/Collection/ArrayLiteralListProvider.swift index 126a0b72b6..1cb9598266 100644 --- a/Amplify/Categories/DataStore/Model/Collection/ArrayLiteralListProvider.swift +++ b/Amplify/Categories/DataStore/Model/Collection/ArrayLiteralListProvider.swift @@ -46,3 +46,22 @@ public struct ArrayLiteralListProvider: ModelListProvider { nil) } } + +// MARK: - SingleModelProvider + +public struct DefaultModelProvider: ModelProvider { + + let element: Element? + public init(element: Element? = nil) { + self.element = element + } + + public func load() async throws -> Element? { + return element + } + + public func getState() -> ModelProviderState { + return .loaded(element) + } + +} diff --git a/Amplify/Categories/DataStore/Model/Collection/List+Model.swift b/Amplify/Categories/DataStore/Model/Collection/List+Model.swift index b01f7177a0..6b99088403 100644 --- a/Amplify/Categories/DataStore/Model/Collection/List+Model.swift +++ b/Amplify/Categories/DataStore/Model/Collection/List+Model.swift @@ -8,6 +8,104 @@ import Foundation import Combine +public class LazyModel: Codable { + /// Represents the data state of the `List`. + enum LoadedState { + case notLoaded + case loaded(Element?) + } + var loadedState: LoadedState + + /// The provider for fulfilling list behaviors + let modelProvider: AnyModelProvider + + public init(modelProvider: AnyModelProvider) { + self.modelProvider = modelProvider + switch self.modelProvider.getState() { + case .loaded(let element): + self.loadedState = .loaded(element) + case .notLoaded: + self.loadedState = .notLoaded + } + } + + public convenience init(element: Element? = nil) { + let modelProvider = DefaultModelProvider(element: element).eraseToAnyModelProvider() + self.init(modelProvider: modelProvider) + } + + required convenience public init(from decoder: Decoder) throws { + for modelDecoder in ModelProviderRegistry.decoders.get() { + if modelDecoder.shouldDecode(modelType: Element.self, decoder: decoder) { + let modelProvider = try modelDecoder.makeModelProvider(modelType: Element.self, decoder: decoder) + self.init(modelProvider: modelProvider) + return + } + } + let json = try JSONValue(from: decoder) + if case .object = json { + let element = try Element(from: decoder) + self.init(element: element) + } else { + self.init() + } + } + + + public func encode(to encoder: Encoder) throws { + switch loadedState { + case .notLoaded: + break + // try Element.encode(to: encoder) + case .loaded(let element): + try element.encode(to: encoder) + } + } + + // MARK: - APIs + + public func get() async throws -> Element? { + switch loadedState { + case .notLoaded: + return nil + case .loaded(let element): + return element + } + } +} + +public struct AnyModelProvider: ModelProvider { + + private let loadAsync: () async throws -> Element? + private let getStateClosure: () -> ModelProviderState + + public init(provider: Provider) where Provider.Element == Self.Element { + self.loadAsync = provider.load + self.getStateClosure = provider.getState + } + public func load() async throws -> Element? { + try await loadAsync() + } + + public func getState() -> ModelProviderState { + getStateClosure() + } +} + +public protocol ModelProvider { + associatedtype Element: Model + + func load() async throws -> Element? + + func getState() -> ModelProviderState + +} + +public enum ModelProviderState { + case notLoaded(id: String, field: String) + case loaded(Element?) +} + /// `List` is a custom `Collection` that is capable of loading records from a data source. This is especially /// useful when dealing with Model associations that need to be lazy loaded. Lazy loading is performed when you access /// the `Collection` methods by retrieving the data from the underlying data source and then stored into this object, diff --git a/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift b/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift index e278845081..d72ae11e96 100644 --- a/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift +++ b/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift @@ -40,3 +40,27 @@ public protocol ModelListDecoder { static func makeListProvider( modelType: ModelType.Type, decoder: Decoder) throws -> AnyModelListProvider } + +// MARK: ModelProviderRegistry + + +public struct ModelProviderRegistry { + public static var decoders = AtomicValue(initialValue: [ModelProviderDecoder.Type]()) + + /// Register a decoder during plugin configuration time, to allow runtime retrievals of list providers. + public static func registerDecoder(_ decoder: ModelProviderDecoder.Type) { + decoders.append(decoder) + } +} + +extension ModelProviderRegistry { + static func reset() { + decoders.set([ModelProviderDecoder.Type]()) + } +} + +public protocol ModelProviderDecoder { + static func shouldDecode(modelType: ModelType.Type, decoder: Decoder) -> Bool + static func makeModelProvider( + modelType: ModelType.Type, decoder: Decoder) throws -> AnyModelProvider +} diff --git a/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift b/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift index ffe658f534..cb4183edda 100644 --- a/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift +++ b/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift @@ -23,7 +23,7 @@ public protocol ModelListMarker { } /// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking /// change. public enum ModelListProviderState { - case notLoaded + case notLoaded(associatedId: String, associatedField: String) case loaded([Element]) } @@ -94,3 +94,12 @@ public extension ModelListProvider { AnyModelListProvider(provider: self) } } + +// MARK - AnyModelProvider + + +public extension ModelProvider { + func eraseToAnyModelProvider() -> AnyModelProvider { + AnyModelProvider(provider: self) + } +} diff --git a/Amplify/Core/Error/CoreError.swift b/Amplify/Core/Error/CoreError.swift index df15d7cd6f..4639d46f18 100644 --- a/Amplify/Core/Error/CoreError.swift +++ b/Amplify/Core/Error/CoreError.swift @@ -10,6 +10,8 @@ public enum CoreError { /// A related operation performed on `List` resulted in an error. case listOperation(ErrorDescription, RecoverySuggestion, Error? = nil) + + case operation(ErrorDescription, RecoverySuggestion, Error? = nil) /// A client side validation error occured. case clientValidation(ErrorDescription, RecoverySuggestion, Error? = nil) @@ -19,6 +21,7 @@ extension CoreError: AmplifyError { public var errorDescription: ErrorDescription { switch self { case .listOperation(let errorDescription, _, _), + .operation(let errorDescription, _, _), .clientValidation(let errorDescription, _, _): return errorDescription } @@ -27,6 +30,7 @@ extension CoreError: AmplifyError { public var recoverySuggestion: RecoverySuggestion { switch self { case .listOperation(_, let recoverySuggestion, _), + .operation(_, let recoverySuggestion, _), .clientValidation(_, let recoverySuggestion, _): return recoverySuggestion } @@ -35,6 +39,7 @@ extension CoreError: AmplifyError { public var underlyingError: Error? { switch self { case .listOperation(_, _, let underlyingError), + .operation(_, _, let underlyingError), .clientValidation(_, _, let underlyingError): return underlyingError } diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/AWSAPIPlugin.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/AWSAPIPlugin.swift index c2e45a8e57..5fe085b42e 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/AWSAPIPlugin.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/AWSAPIPlugin.swift @@ -59,6 +59,7 @@ final public class AWSAPIPlugin: NSObject, APICategoryPlugin, APICategoryGraphQL modelRegistration?.registerModels(registry: ModelRegistry.self) ModelListDecoderRegistry.registerDecoder(AppSyncListDecoder.self) + ModelProviderRegistry.registerDecoder(AppSyncModelDecoder.self) let sessionFactory = sessionFactory ?? URLSessionFactory.makeDefault() self.session = sessionFactory.makeSession(withDelegate: self) diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift index 7fc0d7c0f6..fa9bf17a57 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift @@ -52,3 +52,41 @@ public struct AppSyncListDecoder: ModelListDecoder { return nil } } + + +public struct AppSyncModelDecoder: ModelProviderDecoder { + public static func shouldDecode(modelType: ModelType.Type, decoder: Decoder) -> Bool { + if (try? AppSyncPartialModelMetadata(from: decoder)) != nil { + return true + } + + if (try? ModelType(from: decoder)) != nil { + return true + } + + return false + } + + public static func makeModelProvider(modelType: ModelType.Type, + decoder: Decoder) throws -> AnyModelProvider { + if let appSyncModelProvider = try makeAppSyncModelProvider(modelType: modelType, decoder: decoder) { + return appSyncModelProvider.eraseToAnyModelProvider() + } + + return DefaultModelProvider().eraseToAnyModelProvider() + } + + static func makeAppSyncModelProvider(modelType: ModelType.Type, + decoder: Decoder) throws -> AppSyncModelProvider? { + if let model = try? ModelType.init(from: decoder) { + return try AppSyncModelProvider(model: model) + } else if let metadata = try? AppSyncPartialModelMetadata.init(from: decoder) { + return AppSyncModelProvider(metadata: metadata) + } + let json = try JSONValue(from: decoder) + let message = "AppSyncListProvider could not be created from \(String(describing: json))" + Amplify.DataStore.log.error(message) + assertionFailure(message) + return nil + } +} diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListPayload.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListPayload.swift index 6205e6c448..c3d8f32d3d 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListPayload.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListPayload.swift @@ -64,3 +64,20 @@ public struct AppSyncListPayload: Codable { return nil } } + +// MARK - AppSyncModelPayload + + +public struct AppSyncModelPayload: Codable { + + let graphQLData: JSONValue + let apiName: String? + + public init(graphQLData: JSONValue, + apiName: String?) { + self.apiName = apiName + self.graphQLData = graphQLData + } + + +} diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift index 172c84a503..a1e5f3d5ab 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift @@ -9,6 +9,89 @@ import Foundation import Amplify import AWSPluginsCore +public class AppSyncModelProvider: ModelProvider { + + let apiName: String? + + enum LoadedState { + case notLoaded(identifier: String, + field: String) + case loaded(element: Element?) + } + + var loadedState: LoadedState + + + // init(AppSyncModelPayload) creates a loaded provider + convenience init(model: Element?) throws { + self.init(element: model) + } + + // init(AppSyncModelMetadata) creates a notLoaded provider + convenience init(metadata: AppSyncPartialModelMetadata) { + self.init(identifier: metadata.identifier, + field: metadata.field, + apiName: metadata.apiName) + } + + // Internal initializer for a loaded state + init(element: Element?) { + self.loadedState = .loaded(element: element) + self.apiName = nil + } + + // Internal initializer for not loaded state + init(identifier: String, field: String, apiName: String? = nil) { + self.loadedState = .notLoaded(identifier: identifier, field: field) + self.apiName = apiName + } + + + // MARK: - APIs + + public func load() async throws -> Element? { + + switch loadedState { + case .notLoaded(let identifier, _): + let request = GraphQLRequest.getQuery(responseType: Element.self, + modelSchema: Element.schema, + identifier: identifier, + apiName: apiName) + do { + let graphQLResponse = try await Amplify.API.query(request: request) + switch graphQLResponse { + case .success(let model): + return model + case .failure(let graphQLError): + Amplify.API.log.error(error: graphQLError) + throw CoreError.operation( + "The AppSync response returned successfully with GraphQL errors.", + "Check the underlying error for the failed GraphQL response.", + graphQLError) + } + } catch let apiError as APIError { + Amplify.API.log.error(error: apiError) + throw CoreError.operation("The AppSync request failed", + "See underlying `APIError` for more details.", + apiError) + } catch { + throw error + } + case .loaded(let element): + return element + } + } + + public func getState() -> ModelProviderState { + switch loadedState { + case .notLoaded(let id, let field): + return .notLoaded(id: id, field: field) + case .loaded(let element): + return .loaded(element) + } + } +} + public class AppSyncListProvider: ModelListProvider { /// The API friendly name used to reference the API to call @@ -84,8 +167,8 @@ public class AppSyncListProvider: ModelListProvider { public func getState() -> ModelListProviderState { switch loadedState { - case .notLoaded: - return .notLoaded + case .notLoaded(let associatedId, let associatedField): + return .notLoaded(associatedId: associatedId, associatedField: associatedField) case .loaded(let elements, _, _): return .loaded(elements) } diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListResponse.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListResponse.swift index 7f83ab4ce7..058d30e60e 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListResponse.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListResponse.swift @@ -48,3 +48,36 @@ extension AppSyncListResponse { } } + +// MARK: - AppSyncModelResponse +// +//struct AppSyncModelResponse: Codable { +// +// public let item: Element? +// +// init(item: Element?) { +// self.item = item +// } +//} +// +//extension AppSyncModelResponse { +// static func initWithMetadata(type: Element.Type, +// graphQLData: JSONValue, +// apiName: String?) throws -> AppSyncModelResponse { +// +// var element: Element? = nil +// if case let .object = graphQLData { +// let jsonObjectWithMetadata = AppSyncModelMetadataUtils.addMetadata(toModel: graphQLData, apiName: apiName) +// +// let encoder = JSONEncoder() +// encoder.dateEncodingStrategy = ModelDateFormatting.encodingStrategy +// let decoder = JSONDecoder() +// decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy +// let serializedJSON = try encoder.encode(jsonObjectWithMetadata) +// element = try decoder.decode(type, from: serializedJSON) +// } +// +// return AppSyncModelResponse(item: element) +// } +// +//} diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift index 321865fb06..ba147a2c7b 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift @@ -15,7 +15,21 @@ public struct AppSyncModelMetadata: Codable { let apiName: String? } +/// Metadata that contains partial information of a model +public struct AppSyncPartialModelMetadata: Codable { + let identifier: String + let field: String + let apiName: String? +} + public struct AppSyncModelMetadataUtils { + + // This validation represents the requirements for adding metadata. For example, + // It needs to have the `id` of the model so it can be injected into lazy lists as the "associatedId" + // It needs to have the Model type from `__typename` so it can populate it as "associatedField" + // + // This check is currently broken for CPK use cases since the identifier may not be named `id` anymore + // and also can be a composite key made up of multiple fields. static func shouldAddMetadata(toModel graphQLData: JSONValue) -> Bool { guard case let .object(modelJSON) = graphQLData, case let .string(modelName) = modelJSON["__typename"], @@ -73,6 +87,21 @@ public struct AppSyncModelMetadataUtils { // array association like Comment, store the post's identifier and the ModelField name of the parent, ie. // "post" in the comments object as metadata. for modelField in modelSchema.fields.values { + + if !modelField.isArray && modelField.hasAssociation, + let nestedModelJSON = modelJSON[modelField.name], + let partialModelMetadata = isPartialModel(nestedModelJSON, apiName: apiName) { + + if let serializedMetadata = try? encoder.encode(partialModelMetadata), + let metadataJSON = try? decoder.decode(JSONValue.self, from: serializedMetadata) { + modelJSON.updateValue(metadataJSON, forKey: modelField.name) + } else { + Amplify.API.log.error(""" + Found assocation but failed to add metadata to existing model: \(modelJSON) + """) + } + } + if modelField.isArray && modelField.hasAssociation, let associatedField = modelField.associatedField, modelJSON[modelField.name] == nil { @@ -92,4 +121,28 @@ public struct AppSyncModelMetadataUtils { return JSONValue.object(modelJSON) } + + // A partial model is when only the values of the identifier of the model exists, and nothing else. + // Traverse of the primary keys of the model, and check if there's exactly those values exists. + // This means that the model are missing required/optional fields that are not the identifier of the model. + // TODO: This code needs to account for CPK. + static func isPartialModel(_ modelJSON: JSONValue, apiName: String?) -> AppSyncPartialModelMetadata? { + guard case .object(let modelObject) = modelJSON else { + return nil + } + + guard case .string(let modelName) = modelObject["__typename"] else { + return nil + } + + guard modelObject.count == 2 else { + return nil + } + + if case .string(let id) = modelObject["id"] { + return AppSyncPartialModelMetadata(identifier: id, field: modelName, apiName: apiName) + } + + return nil + } } diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLResponseDecoder+DecodeData.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLResponseDecoder+DecodeData.swift index efa691d19a..be59731a6f 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLResponseDecoder+DecodeData.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLResponseDecoder+DecodeData.swift @@ -10,7 +10,18 @@ import AWSPluginsCore import Foundation extension GraphQLResponseDecoder { - + + /* + The sequence of `responseType` checking attempts to decode to specific types before falling back to (5) + serializing the data and letting the default decode run its course (6). + + 1. String, special case where the object is serialized as a JSON string. + 2. AnyModel, used by DataStore's sync engine + 3. ModelListMarker, checks if it is a List type, inject additional information to create a loaded list. + 4. AppSyncModelMetadataUtils.shouldAddMetadata/addMetadata injects metadata for hasMany associations to + decode to nested notloaded Lists. + 5. Default encode/decode path (6) + */ func decodeToResponseType(_ graphQLData: [String: JSONValue]) throws -> R { let graphQLData = try valueAtDecodePath(from: JSONValue.object(graphQLData)) if request.responseType == String.self { @@ -25,23 +36,34 @@ extension GraphQLResponseDecoder { } let serializedJSON: Data - if request.responseType == AnyModel.self { + + if request.responseType == AnyModel.self { // 2 let anyModel = try AnyModel(modelJSON: graphQLData) serializedJSON = try encoder.encode(anyModel) - } else if request.responseType is ModelListMarker.Type { - let payload = AppSyncListPayload(graphQLData: graphQLData, + } else if request.responseType is ModelListMarker.Type, // 2 + case .object(var graphQLDataObject) = graphQLData, + case .array(var graphQLDataArray) = graphQLDataObject["items"] { + for (index, item) in graphQLDataArray.enumerated() { + if AppSyncModelMetadataUtils.shouldAddMetadata(toModel: item) { // 4 + let modelJSON = AppSyncModelMetadataUtils.addMetadata(toModel: item, + apiName: request.apiName) + graphQLDataArray[index] = modelJSON + } + } + graphQLDataObject["items"] = JSONValue.array(graphQLDataArray) + let payload = AppSyncListPayload(graphQLData: JSONValue.object(graphQLDataObject), apiName: request.apiName, variables: try getVariablesJSON()) serializedJSON = try encoder.encode(payload) - } else if AppSyncModelMetadataUtils.shouldAddMetadata(toModel: graphQLData) { + } else if AppSyncModelMetadataUtils.shouldAddMetadata(toModel: graphQLData) { // 4 let modelJSON = AppSyncModelMetadataUtils.addMetadata(toModel: graphQLData, apiName: request.apiName) serializedJSON = try encoder.encode(modelJSON) - } else { + } else { // 5 serializedJSON = try encoder.encode(graphQLData) } - return try decoder.decode(request.responseType, from: serializedJSON) + return try decoder.decode(request.responseType, from: serializedJSON) // 6 } // MARK: - Helper methods diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Utils/GraphQLRequest+toListQuery.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Utils/GraphQLRequest+toListQuery.swift index 1d289f1133..1752361429 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Utils/GraphQLRequest+toListQuery.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Utils/GraphQLRequest+toListQuery.swift @@ -30,4 +30,23 @@ extension GraphQLRequest { responseType: responseType.self, decodePath: document.name) } + + static func getQuery(responseType: ResponseType.Type, + modelSchema: ModelSchema, + identifier: String, + apiName: String? = nil) -> GraphQLRequest { + + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, + operationType: .query) + documentBuilder.add(decorator: DirectiveNameDecorator(type: .get)) + documentBuilder.add(decorator: ModelIdDecorator(id: identifier)) + + let document = documentBuilder.build() + return GraphQLRequest(apiName: apiName, + document: document.stringValue, + variables: document.variables, + responseType: responseType.self, + decodePath: document.name) + } + } diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift new file mode 100644 index 0000000000..4058a80678 --- /dev/null +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift @@ -0,0 +1,687 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +@testable import Amplify +import AWSPluginsCore +@testable import AmplifyTestCommon +@testable import AWSAPIPlugin + +// Decoder tests for ParentPost4V2 and ChildComment4V2 +class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { + + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + override func setUp() async throws { + await Amplify.reset() + ModelRegistry.register(modelType: LazyParentPost4V2.self) + ModelRegistry.register(modelType: LazyChildComment4V2.self) + ModelListDecoderRegistry.registerDecoder(AppSyncListDecoder.self) + ModelProviderRegistry.registerDecoder(AppSyncModelDecoder.self) + + decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy + encoder.dateEncodingStrategy = ModelDateFormatting.encodingStrategy + } + + /* + This test will start to fail if we remove the optionality on the LazyModel type, from + ``` + public struct LazyChildComment4V2: Model { + public var post: LazyModel? + ``` + to + ``` + public struct LazyChildComment4V2: Model { + public var post: LazyModel + ``` + with the error + ``` + "keyNotFound(CodingKeys(stringValue: "post", intValue: nil), + Swift.DecodingError.Context(codingPath: [], + debugDescription: "No value associated with key CodingKeys(stringValue: \"post\", intValue: nil) + (\"post\").", underlyingError: nil))" + ``` + */ + func testGetChildModel() throws { + let request = GraphQLRequest(document: "", + responseType: LazyChildComment4V2.self, + decodePath: "getLazyChildComment4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "getLazyChildComment4V2": [ + "id": "id", + "content": "content" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.id, "id") + XCTAssertEqual(result.content, "content") + } + + func testGetParentModel() throws { + let request = GraphQLRequest(document: "", + responseType: LazyParentPost4V2.self, + decodePath: "getLazyParentPost4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "getLazyParentPost4V2": [ + "id": "id", + "title": "title" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.id, "id") + XCTAssertEqual(result.title, "title") + } + + func testListChildModel() throws { + let request = GraphQLRequest>(document: "", + responseType: List.self, + decodePath: "listLazyChildComment4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "listLazyChildComment4V2": [ + "items": [ + [ + "id": "id1", + "content": "content1" + ], + [ + "id": "id2", + "content": "content2" + ] + ] + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.count, 2) + let comment1 = result.first { $0.id == "id1" } + let comment2 = result.first { $0.id == "id2" } + XCTAssertNotNil(comment1) + XCTAssertNotNil(comment2) + } + + func testListParentModel() throws { + let request = GraphQLRequest>(document: "", + responseType: List.self, + decodePath: "listLazyParentPost4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "listLazyParentPost4V2": [ + "items": [ + [ + "id": "id1", + "title": "title" + ], + [ + "id": "id2", + "title": "title" + ] + ] + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.count, 2) + let post1 = result.first { $0.id == "id1" } + let post2 = result.first { $0.id == "id2" } + XCTAssertNotNil(post1) + XCTAssertNotNil(post2) + } + + func testPostHasLazyLoadComments() throws { + let request = GraphQLRequest.get(LazyParentPost4V2.self, byId: "id") + let documentStringValue = """ + query GetLazyParentPost4V2($id: ID!) { + getLazyParentPost4V2(id: $id) { + id + createdAt + title + updatedAt + __typename + } + } + """ + XCTAssertEqual(request.document, documentStringValue) + XCTAssertEqual(request.decodePath, "getLazyParentPost4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "\(request.decodePath!)": [ + "id": "postId", + "title": "title", + "__typename": "LazyParentPost4V2" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + guard let post = result else { + XCTFail("Failed to decode to post") + return + } + XCTAssertEqual(post.id, "postId") + XCTAssertEqual(post.title, "title") + guard let comments = post.comments else { + XCTFail("Could not create list of comments") + return + } + let state = comments.listProvider.getState() + switch state { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, "postId") + XCTAssertEqual(associatedField, "post") + case .loaded: + XCTFail("Should be not loaded") + } + } + + func testPostHasEagerLoadedComments() throws { + // Since we are mocking `graphQLData` below, it does not matter what selection set is contained + // inside the `document` parameter, however for an integration level test, the custom selection set + // should contain two levels, the post fields and the nested comment fields. + let request = GraphQLRequest(document: "", + responseType: LazyParentPost4V2?.self, + decodePath: "getLazyParentPost4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "getLazyParentPost4V2": [ + "id": "postId", + "title": "title", + "__typename": "LazyParentPost4V2", + "comments": [ + [ + "id": "id1", + "content": "content1" + ], + [ + "id": "id2", + "content": "content2" + ] + ] + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + guard let post = result else { + XCTFail("Failed to decode to post") + return + } + XCTAssertEqual(post.id, "postId") + XCTAssertEqual(post.title, "title") + guard let comments = post.comments else { + XCTFail("Could not create list of comments") + return + } + let state = comments.listProvider.getState() + switch state { + case .notLoaded: + XCTFail("Should be loaded") + case .loaded(let comments): + XCTAssertEqual(comments.count, 2) + let comment1 = comments.first { $0.id == "id1" } + let comment2 = comments.first { $0.id == "id2" } + XCTAssertNotNil(comment1) + XCTAssertNotNil(comment2) + } + } + + func testCommentHasEagerLoadedPost() throws { + // By default, the `.get` for a child model with belongs-to parent creates a nested selection set + // as shown below by `documentStringValue`, so we mock the `graphQLData` with a nested object + // comment containing a post + let request = GraphQLRequest.get(LazyChildComment4V2.self, byId: "id") + let documentStringValue = """ + query GetLazyChildComment4V2($id: ID!) { + getLazyChildComment4V2(id: $id) { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + } + """ + XCTAssertEqual(request.document, documentStringValue) + XCTAssertEqual(request.decodePath, "getLazyChildComment4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "getLazyChildComment4V2": [ + "id": "id", + "content": "content", + "post": [ + "id": "postId", + "title": "title", + "__typename": "LazyParentPost4V2" + ], + "__typename": "LazyChildComment4V2" + ] + ] + + let comment = try decoder.decodeToResponseType(graphQLData) + guard let comment = comment else { + XCTFail("Could not load comment") + return + } + + XCTAssertEqual(comment.id, "id") + XCTAssertEqual(comment.content, "content") + guard let post = comment.post else { + XCTFail("LazModel should be created") + return + } + switch post.modelProvider.getState() { + case .notLoaded: + XCTFail("Should have been loaded") + case .loaded(let post): + guard let post = post else { + XCTFail("Loaded with no post") + return + } + XCTAssertEqual(post.id, "postId") + XCTAssertEqual(post.title, "title") + } + } + + func testCommentHasLazyLoadPostFromPartialPost() throws { + let request = GraphQLRequest.get(LazyChildComment4V2.self, byId: "id") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + // The data used to seed the decoder contains the nested `post` that mimics an incomplete selection set + // so it is missing some required fields such as `title` and still be successful in creating a "not loaded" + // lazy model object. + let graphQLData: [String: JSONValue] = [ + "getLazyChildComment4V2": [ + "id": "id", + "content": "content", + "post": [ + "id": "postId", + "__typename": "LazyParentPost4V2" + ], + "__typename": "LazyChildComment4V2" + ] + ] + + let comment = try decoder.decodeToResponseType(graphQLData) + guard let comment = comment else { + XCTFail("Could not load comment") + return + } + + XCTAssertEqual(comment.id, "id") + XCTAssertEqual(comment.content, "content") + guard let post = comment.post else { + XCTFail("LazModel should be created") + return + } + switch post.modelProvider.getState() { + case .notLoaded(let id, let field): + XCTAssertEqual(id, "postId") + XCTAssertEqual(field, "LazyParentPost4V2") + case .loaded: + XCTFail("Should be not loaded") + } + } + + func testCommentHasLazyLoadPostFromNilPost() throws { + let request = GraphQLRequest.get(LazyChildComment4V2.self, byId: "id") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + // The data used to seed the decoder contains the nested `post` that mimics an incomplete selection set + // so it is missing some required fields such as `title` and still be successfully in creating a "not loaded" + // lazy model. When the LazyModel decoder runs, it is responsible for attempting to decode to the right state + // either loaded if the entire post object is there, or not loaded when the minimum required "not loaded" + // information is there. + let graphQLData: [String: JSONValue] = [ + "getLazyChildComment4V2": [ + "id": "commentId", + "content": "content", + "post": nil, + "__typename": "LazyChildComment4V2" + ] + ] + + let comment = try decoder.decodeToResponseType(graphQLData) + guard let comment = comment else { + XCTFail("Could not load comment") + return + } + + XCTAssertEqual(comment.id, "commentId") + XCTAssertEqual(comment.content, "content") + guard comment.post == nil else { + XCTFail("lazy model should be nil") + return + } + } + + func testCommentHasLazyLoadPostFromEmptyPost() throws { + let request = GraphQLRequest.get(LazyChildComment4V2.self, byId: "id") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + let graphQLData: [String: JSONValue] = [ + "getLazyChildComment4V2": [ + "id": "id", + "content": "content", + "__typename": "LazyChildComment4V2" + ] + ] + + let comment = try decoder.decodeToResponseType(graphQLData) + guard let comment = comment else { + XCTFail("Could not load comment") + return + } + + XCTAssertEqual(comment.id, "id") + XCTAssertEqual(comment.content, "content") + guard comment.post == nil else { + XCTFail("LazModel should not be nil") + return + } + } + + func testListCommentHasEagerLoadedPost() throws { + // By default, the `.list` for a list of children models with belongs-to parent creates a nested selection set + // as shown below by `documentStringValue`, so we mock the `graphQLData` with a list of nested objects + // comments, each containing a post + let request = GraphQLRequest>.list(LazyChildComment4V2.self) + let documentStringValue = """ + query ListLazyChildComment4V2s($limit: Int) { + listLazyChildComment4V2s(limit: $limit) { + items { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + nextToken + } + } + """ + XCTAssertEqual(request.document, documentStringValue) + XCTAssertEqual(request.decodePath, "listLazyChildComment4V2s") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "listLazyChildComment4V2s": [ + "items": [ + [ + "id": "id1", + "content": "content1", + "__typename": "LazyChildComment4V2", + "post": [ + "id": "postId1", + "title": "title1", + "__typename": "LazyParentPost4V2" + ] + ], + [ + "id": "id2", + "content": "content2", + "__typename": "LazyChildComment4V2", + "post": [ + "id": "postId2", + "title": "title2", + "__typename": "LazyParentPost4V2" + ] + ] + ] + ] + ] + + let comments = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(comments.count, 2) + guard let comment1 = comments.first(where: { $0.id == "id1" }) else { + XCTFail("Couldn't find comment with `id1`") + return + } + guard let comment2 = comments.first(where: { $0.id == "id2" }) else { + XCTFail("Couldn't find comment with `id2`") + return + } + guard let post1 = comment1.post else { + XCTFail("missing post on comment1") + return + } + guard let post2 = comment2.post else { + XCTFail("missing post on comment2") + return + } + + switch post1.modelProvider.getState() { + case .notLoaded: + XCTFail("Should be loaded") + case .loaded(let post): + XCTAssertEqual(post!.id, "postId1") + XCTAssertEqual(post!.title, "title1") + } + switch post2.modelProvider.getState() { + case .notLoaded: + XCTFail("Should be loaded") + case .loaded(let post): + XCTAssertEqual(post!.id, "postId2") + XCTAssertEqual(post!.title, "title2") + print("\(post!.comments?.listProvider)") + } + } + + func testListCommentHasLazyLoadedPartialPost() throws { + // By default, the `.list` for a list of children models with belongs-to parent creates a nested selection set + // as shown below by `documentStringValue`, so we mock the `graphQLData` with a list of nested objects + // comments, each containing a partial post, so that the post will be "not loaded" and requires lazy loading + let request = GraphQLRequest>.list(LazyChildComment4V2.self) + XCTAssertEqual(request.decodePath, "listLazyChildComment4V2s") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "listLazyChildComment4V2s": [ + "items": [ + [ + "id": "id1", + "content": "content1", + "__typename": "LazyChildComment4V2", + "post": [ + "id": "postId1", + "__typename": "LazyParentPost4V2" + ] + ], + [ + "id": "id2", + "content": "content2", + "__typename": "LazyChildComment4V2", + "post": [ + "id": "postId2", + "__typename": "LazyParentPost4V2" + ] + ] + ] + ] + ] + + let comments = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(comments.count, 2) + guard let comment1 = comments.first(where: { $0.id == "id1" }) else { + XCTFail("Couldn't find comment with `id1`") + return + } + guard let comment2 = comments.first(where: { $0.id == "id2" }) else { + XCTFail("Couldn't find comment with `id2`") + return + } + guard let post1 = comment1.post else { + XCTFail("missing post on comment1") + return + } + guard let post2 = comment2.post else { + XCTFail("missing post on comment2") + return + } + + switch post1.modelProvider.getState() { + case .notLoaded(let identifier, _): + XCTAssertEqual(identifier, "postId1") + case .loaded: + XCTFail("Should be not loaded") + } + switch post2.modelProvider.getState() { + case .notLoaded(let identifier, _): + XCTAssertEqual(identifier, "postId2") + case .loaded: + XCTFail("Should be not loaded") + } + } +} + + +// MARK: - Models + +public struct LazyParentPost4V2: Model { + public let id: String + public var title: String + public var comments: List? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + title: String, + comments: List? = []) { + self.init(id: id, + title: title, + comments: comments, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + title: String, + comments: List? = [], + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.title = title + self.comments = comments + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} +extension LazyParentPost4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case title + case comments + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let post4V2 = Post4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Post4V2s" + + model.fields( + .id(), + .field(post4V2.title, is: .required, ofType: .string), + .hasMany(post4V2.comments, is: .optional, ofType: LazyChildComment4V2.self, associatedWith: LazyChildComment4V2.keys.post), + .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +public struct LazyChildComment4V2: Model { + public let id: String + public var content: String + public var post: LazyModel? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + content: String, + post: LazyParentPost4V2? = nil) { + self.init(id: id, + content: content, + post: post, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + content: String, + post: LazyParentPost4V2? = nil, + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.content = content + self.post = LazyModel(element: post) + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +extension LazyChildComment4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case content + case post + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let comment4V2 = Comment4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Comment4V2s" + + model.attributes( + .index(fields: ["postID", "content"], name: "byPost4") + ) + + model.fields( + .id(), + .field(comment4V2.content, is: .required, ofType: .string), + .belongsTo(comment4V2.post, is: .optional, ofType: LazyParentPost4V2.self, targetName: "postID"), + .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift new file mode 100644 index 0000000000..053a5b1030 --- /dev/null +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift @@ -0,0 +1,502 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +@testable import Amplify +import AWSPluginsCore +@testable import AmplifyTestCommon +@testable import AWSAPIPlugin + +// Decoder tests for ParentPost4V2 and ChildComment4V2 +class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase { + + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + override func setUp() async throws { + await Amplify.reset() + ModelRegistry.register(modelType: ParentPost4V2.self) + ModelRegistry.register(modelType: ChildComment4V2.self) + ModelListDecoderRegistry.registerDecoder(AppSyncListDecoder.self) + ModelProviderRegistry.registerDecoder(AppSyncModelDecoder.self) + + decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy + encoder.dateEncodingStrategy = ModelDateFormatting.encodingStrategy + } + + func testDecodeChildCommentResponseTypeForString() throws { + let request = GraphQLRequest(document: "", + responseType: String.self, + decodePath: "getChildComment4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "getChildComment4V2": [ + "id": "id" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result, "{\"id\":\"id\"}") + } + + func testGetChildModel() throws { + let request = GraphQLRequest(document: "", + responseType: ChildComment4V2.self, + decodePath: "getChildComment4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "getChildComment4V2": [ + "id": "id", + "content": "content" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.id, "id") + XCTAssertEqual(result.content, "content") + } + + func testGetParentModel() throws { + let request = GraphQLRequest(document: "", + responseType: ParentPost4V2.self, + decodePath: "getParentPost4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "getParentPost4V2": [ + "id": "id", + "title": "title" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.id, "id") + XCTAssertEqual(result.title, "title") + } + + func testListChildModel() throws { + let request = GraphQLRequest>(document: "", + responseType: List.self, + decodePath: "listChildComment4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "listChildComment4V2": [ + "items": [ + [ + "id": "id1", + "content": "content1" + ], + [ + "id": "id2", + "content": "content2" + ] + ] + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.count, 2) + let comment1 = result.first { $0.id == "id1" } + let comment2 = result.first { $0.id == "id2" } + XCTAssertNotNil(comment1) + XCTAssertNotNil(comment2) + } + + func testListParentModel() throws { + let request = GraphQLRequest>(document: "", + responseType: List.self, + decodePath: "listParentPost4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "listParentPost4V2": [ + "items": [ + [ + "id": "id1", + "title": "title" + ], + [ + "id": "id2", + "title": "title" + ] + ] + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.count, 2) + let post1 = result.first { $0.id == "id1" } + let post2 = result.first { $0.id == "id2" } + XCTAssertNotNil(post1) + XCTAssertNotNil(post2) + } + + func testPostHasLazyLoadComments() throws { + let request = GraphQLRequest.get(ParentPost4V2.self, byId: "id") + let documentStringValue = """ + query GetParentPost4V2($id: ID!) { + getParentPost4V2(id: $id) { + id + createdAt + title + updatedAt + __typename + } + } + """ + XCTAssertEqual(request.document, documentStringValue) + XCTAssertEqual(request.decodePath, "getParentPost4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "\(request.decodePath!)": [ + "id": "postId", + "title": "title", + "__typename": "ParentPost4V2" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + guard let post = result else { + XCTFail("Failed to decode to post") + return + } + XCTAssertEqual(post.id, "postId") + XCTAssertEqual(post.title, "title") + guard let comments = post.comments else { + XCTFail("Could not create list of comments") + return + } + let state = comments.listProvider.getState() + switch state { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, "postId") + XCTAssertEqual(associatedField, "post") + case .loaded: + XCTFail("Should be not loaded") + } + } + + func testPostHasEagerLoadedComments() throws { + // Since we are mocking `graphQLData` below, it does not matter what selection set is contained + // inside the `document` parameter, however for an integration level test, the custom selection set + // should contain two levels, the post fields and the nested comment fields. + let request = GraphQLRequest(document: "", + responseType: ParentPost4V2?.self, + decodePath: "getParentPost4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "getParentPost4V2": [ + "id": "postId", + "title": "title", + "__typename": "ParentPost4V2", + "comments": [ + [ + "id": "id1", + "content": "content1" + ], + [ + "id": "id2", + "content": "content2" + ] + ] + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + guard let post = result else { + XCTFail("Failed to decode to post") + return + } + XCTAssertEqual(post.id, "postId") + XCTAssertEqual(post.title, "title") + guard let comments = post.comments else { + XCTFail("Could not create list of comments") + return + } + let state = comments.listProvider.getState() + switch state { + case .notLoaded: + XCTFail("Should be loaded") + case .loaded(let comments): + XCTAssertEqual(comments.count, 2) + let comment1 = comments.first { $0.id == "id1" } + let comment2 = comments.first { $0.id == "id2" } + XCTAssertNotNil(comment1) + XCTAssertNotNil(comment2) + } + } + + func testCommentHasEagerLoadedPost() throws { + // By default, the `.get` for a child model with belongs-to parent creates a nested selection set + // as shown below by `documentStringValue`, so we mock the `graphQLData` with a nested object + // comment containing a post + let request = GraphQLRequest.get(ChildComment4V2.self, byId: "id") + let documentStringValue = """ + query GetChildComment4V2($id: ID!) { + getChildComment4V2(id: $id) { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + } + """ + XCTAssertEqual(request.document, documentStringValue) + XCTAssertEqual(request.decodePath, "getChildComment4V2") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "getChildComment4V2": [ + "id": "id", + "content": "content", + "post": [ + "id": "postId", + "title": "title", + "__typename": "ParentPost4V2" + ], + "__typename": "ChildComment4V2" + ] + ] + + let comment = try decoder.decodeToResponseType(graphQLData) + guard let comment = comment else { + XCTFail("Could not load comment") + return + } + + XCTAssertEqual(comment.id, "id") + XCTAssertEqual(comment.content, "content") + guard let post = comment.post else { + XCTFail("post should be eager loaded") + return + } + XCTAssertEqual(post.id, "postId") + XCTAssertEqual(post.title, "title") + } + + func testListCommentHasEagerLoadedPost() throws { + // By default, the `.list` for a list of children models with belongs-to parent creates a nested selection set + // as shown below by `documentStringValue`, so we mock the `graphQLData` with a list of nested objects + // comments, each containing a post + let request = GraphQLRequest>.list(ChildComment4V2.self) + let documentStringValue = """ + query ListChildComment4V2s($limit: Int) { + listChildComment4V2s(limit: $limit) { + items { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + nextToken + } + } + """ + XCTAssertEqual(request.document, documentStringValue) + XCTAssertEqual(request.decodePath, "listChildComment4V2s") + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + + let graphQLData: [String: JSONValue] = [ + "listChildComment4V2s": [ + "items": [ + [ + "id": "id1", + "content": "content1", + "__typename": "LazyChildComment4V2", + "post": [ + "id": "postId1", + "title": "title1", + "__typename": "LazyParentPost4V2" + ] + ], + [ + "id": "id2", + "content": "content2", + "__typename": "LazyChildComment4V2", + "post": [ + "id": "postId2", + "title": "title2", + "__typename": "LazyParentPost4V2" + ] + ] + ] + ] + ] + + let comments = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(comments.count, 2) + guard let comment1 = comments.first(where: { $0.id == "id1" }) else { + XCTFail("Couldn't find comment with `id1`") + return + } + guard let comment2 = comments.first(where: { $0.id == "id2" }) else { + XCTFail("Couldn't find comment with `id2`") + return + } + guard let post1 = comment1.post else { + XCTFail("missing post on comment1") + return + } + XCTAssertEqual(post1.id, "postId1") + XCTAssertEqual(post1.title, "title1") + guard let post2 = comment2.post else { + XCTFail("missing post on comment2") + return + } + XCTAssertEqual(post2.id, "postId2") + XCTAssertEqual(post2.title, "title2") + } +} + + +// MARK: - Models + +public struct ParentPost4V2: Model { + public let id: String + public var title: String + public var comments: List? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + title: String, + comments: List? = []) { + self.init(id: id, + title: title, + comments: comments, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + title: String, + comments: List? = [], + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.title = title + self.comments = comments + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} +extension ParentPost4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case title + case comments + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let post4V2 = Post4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Post4V2s" + + model.fields( + .id(), + .field(post4V2.title, is: .required, ofType: .string), + .hasMany(post4V2.comments, is: .optional, ofType: ChildComment4V2.self, associatedWith: ChildComment4V2.keys.post), + .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +public struct ChildComment4V2: Model { + public let id: String + public var content: String + public var post: ParentPost4V2? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + content: String, + post: ParentPost4V2? = nil) { + self.init(id: id, + content: content, + post: post, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + content: String, + post: ParentPost4V2? = nil, + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.content = content + self.post = post + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +extension ChildComment4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case content + case post + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let comment4V2 = Comment4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Comment4V2s" + + model.attributes( + .index(fields: ["postID", "content"], name: "byPost4") + ) + + model.fields( + .id(), + .field(comment4V2.content, is: .required, ofType: .string), + .belongsTo(comment4V2.post, is: .optional, ofType: ParentPost4V2.self, targetName: "postID"), + .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderTests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderTests.swift index 33ea113b3b..949232cae1 100644 --- a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderTests.swift +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderTests.swift @@ -20,7 +20,10 @@ class GraphQLResponseDecoderTests: XCTestCase { ModelRegistry.register(modelType: SimpleModel.self) ModelRegistry.register(modelType: Post4.self) ModelRegistry.register(modelType: Comment4.self) + ModelRegistry.register(modelType: ParentPost4V2.self) + ModelRegistry.register(modelType: ChildComment4V2.self) ModelListDecoderRegistry.registerDecoder(AppSyncListDecoder.self) + ModelProviderRegistry.registerDecoder(AppSyncModelDecoder.self) decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy encoder.dateEncodingStrategy = ModelDateFormatting.encodingStrategy diff --git a/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift b/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift index 417d46bd98..c916046a7c 100644 --- a/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift +++ b/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift @@ -18,19 +18,6 @@ protocol ModelGraphQLRequestFactory { // MARK: Query - /// Creates a `GraphQLRequest` that represents a query that expects multiple values as a result. - /// The request will be created with the correct document based on the `ModelSchema` and - /// variables based on the the predicate. - /// - /// - Parameters: - /// - modelType: the metatype of the model - /// - predicate: an optional predicate containing the criteria for the query - /// - Returns: a valid `GraphQLRequest` instance - /// - /// - seealso: `GraphQLQuery`, `GraphQLQueryType.list` - static func list(_ modelType: M.Type, - where predicate: QueryPredicate?) -> GraphQLRequest<[M]> - /// Creates a `GraphQLRequest` that represents a query that expects multiple values as a result. /// The request will be created with the correct document based on the `ModelSchema` and /// variables based on the the predicate. @@ -214,27 +201,8 @@ extension GraphQLRequest: ModelGraphQLRequestFactory { } public static func list(_ modelType: M.Type, - where predicate: QueryPredicate? = nil) -> GraphQLRequest<[M]> { - var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, - operationType: .query) - documentBuilder.add(decorator: DirectiveNameDecorator(type: .list)) - - if let predicate = predicate { - documentBuilder.add(decorator: FilterDecorator(filter: predicate.graphQLFilter(for: modelType.schema))) - } - - documentBuilder.add(decorator: PaginationDecorator()) - let document = documentBuilder.build() - - return GraphQLRequest<[M]>(document: document.stringValue, - variables: document.variables, - responseType: [M].self, - decodePath: document.name + ".items") - } - - public static func list(_ modelType: M.Type, - where predicate: QueryPredicate? = nil, - limit: Int? = nil) -> GraphQLRequest> { + where predicate: QueryPredicate? = nil, + limit: Int? = nil) -> GraphQLRequest> { var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, operationType: .query) documentBuilder.add(decorator: DirectiveNameDecorator(type: .list)) diff --git a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginAuthCognitoTests/README.md b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginAuthCognitoTests/README.md index fc1300708d..0b864e1c1b 100644 --- a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginAuthCognitoTests/README.md +++ b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginAuthCognitoTests/README.md @@ -1,4 +1,4 @@ -## DataStore with Auth Integration Tests +## DataStore with Auth Cognito Integration Tests The following steps demonstrate how to setup a GraphQL endpoint with AppSync and Cognito User Pools. This configuration is used to run the tests in `AWSDataStoreCategoryPluginAuthIntegrationTests.swift`. From 3be957f846e32cf8700aaa783b10c07fb5d7bc29 Mon Sep 17 00:00:00 2001 From: Michael Law <1365977+lawmicha@users.noreply.github.com> Date: Wed, 24 Aug 2022 11:32:46 -0400 Subject: [PATCH 2/8] feat: LazyModel with DataStoreModelProvider --- .../xcschemes/AWSAPIPlugin.xcscheme | 3 + .../DataStoreCategory+Resettable.swift | 1 + .../Model/Internal/ModelListDecoder.swift | 23 - .../Model/Internal/ModelListProvider.swift | 1 - .../Model/Internal/ModelProvider.swift | 46 ++ .../Model/Internal/ModelProviderDecoder.swift | 31 + .../ArrayLiteralListProvider.swift | 19 - .../Model/Lazy/DefaultModelProvider.swift | 27 + .../DataStore/Model/Lazy/LazyModel.swift | 99 ++++ .../{Collection => Lazy}/List+Combine.swift | 0 .../{Collection => Lazy}/List+LazyLoad.swift | 0 .../{Collection => Lazy}/List+Model.swift | 98 ---- .../List+Pagination.swift | 0 .../Core/AppSyncListDecoder.swift | 2 +- .../Core/AppSyncListProvider.swift | 44 +- .../Core/AppSyncListResponse.swift | 33 -- .../Core/AppSyncModelMetadata.swift | 8 +- .../Operation/AWSRESTOperationTests.swift | 1 + ...sponseDecoderLazyPostComment4V2Tests.swift | 18 +- ...QLResponseDecoderPostComment4V2Tests.swift | 10 +- .../Decode/GraphQLResponseDecoderTests.swift | 2 - .../Model/Support/Model+GraphQL.swift | 3 + ...ataStorePlugin+DataStoreBaseBehavior.swift | 4 +- ...orePlugin+DataStoreSubscribeBehavior.swift | 1 + .../AWSDataStorePlugin.swift | 1 + .../Core/DataStoreListProvider.swift | 4 +- .../Core/DataStoreModelDecoder.swift | 46 ++ .../Core/DataStoreModelProvider.swift | 50 ++ .../Storage/CascadeDeleteOperation.swift | 3 +- .../Storage/ModelStorageBehavior.swift | 2 + .../Storage/SQLite/Model+SQLite.swift | 4 +- .../Storage/SQLite/SQLStatement+Select.swift | 18 +- .../Storage/SQLite/Statement+Model.swift | 52 +- .../SQLite/StorageEngineAdapter+SQLite.swift | 9 +- .../Storage/StorageEngine.swift | 4 + .../DataStoreObserveQueryOperation.swift | 1 + ...nDatabaseAdapter+MutationEventSource.swift | 3 +- .../MutationEventClearState.swift | 3 +- .../OutgoingMutationQueue.swift | 3 +- .../Sync/Support/MutationEvent+Query.swift | 3 +- .../Core/SQLStatementTests.swift | 8 + .../Storage/StorageEngineTestsBase.swift | 47 ++ .../Storage/StorageEngineTestsHasMany.swift | 2 +- ...geEngineTestsLazyPostComment4V2Tests.swift | 530 ++++++++++++++++++ ...torageEngineTestsPostComment4V2Tests.swift | 328 +++++++++++ .../MockSQLiteStorageEngineAdapter.swift | 5 + 46 files changed, 1349 insertions(+), 251 deletions(-) create mode 100644 Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift create mode 100644 Amplify/Categories/DataStore/Model/Internal/ModelProviderDecoder.swift rename Amplify/Categories/DataStore/Model/{Collection => Lazy}/ArrayLiteralListProvider.swift (78%) create mode 100644 Amplify/Categories/DataStore/Model/Lazy/DefaultModelProvider.swift create mode 100644 Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift rename Amplify/Categories/DataStore/Model/{Collection => Lazy}/List+Combine.swift (100%) rename Amplify/Categories/DataStore/Model/{Collection => Lazy}/List+LazyLoad.swift (100%) rename Amplify/Categories/DataStore/Model/{Collection => Lazy}/List+Model.swift (69%) rename Amplify/Categories/DataStore/Model/{Collection => Lazy}/List+Pagination.swift (100%) create mode 100644 AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift create mode 100644 AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift create mode 100644 AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift create mode 100644 AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsPostComment4V2Tests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AWSAPIPlugin.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AWSAPIPlugin.xcscheme index 63be41ddc7..6c7ae68129 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/AWSAPIPlugin.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/AWSAPIPlugin.xcscheme @@ -38,6 +38,9 @@ ReferencedContainer = "container:"> + + diff --git a/Amplify/Categories/DataStore/Internal/DataStoreCategory+Resettable.swift b/Amplify/Categories/DataStore/Internal/DataStoreCategory+Resettable.swift index 8c3ea284c4..679bda609d 100644 --- a/Amplify/Categories/DataStore/Internal/DataStoreCategory+Resettable.swift +++ b/Amplify/Categories/DataStore/Internal/DataStoreCategory+Resettable.swift @@ -23,6 +23,7 @@ extension DataStoreCategory: Resettable { log.verbose("Resetting ModelRegistry and ModelListDecoderRegistry") ModelRegistry.reset() ModelListDecoderRegistry.reset() + ModelProviderRegistry.reset() log.verbose("Resetting ModelRegistry and ModelListDecoderRegistry: finished") isConfigured = false diff --git a/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift b/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift index d72ae11e96..8b7e7534a9 100644 --- a/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift +++ b/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift @@ -41,26 +41,3 @@ public protocol ModelListDecoder { modelType: ModelType.Type, decoder: Decoder) throws -> AnyModelListProvider } -// MARK: ModelProviderRegistry - - -public struct ModelProviderRegistry { - public static var decoders = AtomicValue(initialValue: [ModelProviderDecoder.Type]()) - - /// Register a decoder during plugin configuration time, to allow runtime retrievals of list providers. - public static func registerDecoder(_ decoder: ModelProviderDecoder.Type) { - decoders.append(decoder) - } -} - -extension ModelProviderRegistry { - static func reset() { - decoders.set([ModelProviderDecoder.Type]()) - } -} - -public protocol ModelProviderDecoder { - static func shouldDecode(modelType: ModelType.Type, decoder: Decoder) -> Bool - static func makeModelProvider( - modelType: ModelType.Type, decoder: Decoder) throws -> AnyModelProvider -} diff --git a/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift b/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift index cb4183edda..9d75eca3a3 100644 --- a/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift +++ b/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift @@ -6,7 +6,6 @@ // import Foundation -import Combine /// Empty protocol used as a marker to detect when the type is a `List` /// diff --git a/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift b/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift new file mode 100644 index 0000000000..829a0c94fb --- /dev/null +++ b/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift @@ -0,0 +1,46 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol LazyModelMarker { + associatedtype Element: Model + + var element: Element? { get } +} + +public struct AnyModelProvider: ModelProvider { + + private let loadAsync: () async throws -> Element? + private let getStateClosure: () -> ModelProviderState + + public init(provider: Provider) where Provider.Element == Self.Element { + self.loadAsync = provider.load + self.getStateClosure = provider.getState + } + public func load() async throws -> Element? { + try await loadAsync() + } + + public func getState() -> ModelProviderState { + getStateClosure() + } +} + +public protocol ModelProvider { + associatedtype Element: Model + + func load() async throws -> Element? + + func getState() -> ModelProviderState + +} + +public enum ModelProviderState { + case notLoaded(identifier: String) + case loaded(Element?) +} diff --git a/Amplify/Categories/DataStore/Model/Internal/ModelProviderDecoder.swift b/Amplify/Categories/DataStore/Model/Internal/ModelProviderDecoder.swift new file mode 100644 index 0000000000..477de04934 --- /dev/null +++ b/Amplify/Categories/DataStore/Model/Internal/ModelProviderDecoder.swift @@ -0,0 +1,31 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// MARK: ModelProviderRegistry + +public struct ModelProviderRegistry { + public static var decoders = AtomicValue(initialValue: [ModelProviderDecoder.Type]()) + + /// Register a decoder during plugin configuration time, to allow runtime retrievals of list providers. + public static func registerDecoder(_ decoder: ModelProviderDecoder.Type) { + decoders.append(decoder) + } +} + +extension ModelProviderRegistry { + static func reset() { + decoders.set([ModelProviderDecoder.Type]()) + } +} + +public protocol ModelProviderDecoder { + static func shouldDecode(modelType: ModelType.Type, decoder: Decoder) -> Bool + static func makeModelProvider( + modelType: ModelType.Type, decoder: Decoder) throws -> AnyModelProvider +} diff --git a/Amplify/Categories/DataStore/Model/Collection/ArrayLiteralListProvider.swift b/Amplify/Categories/DataStore/Model/Lazy/ArrayLiteralListProvider.swift similarity index 78% rename from Amplify/Categories/DataStore/Model/Collection/ArrayLiteralListProvider.swift rename to Amplify/Categories/DataStore/Model/Lazy/ArrayLiteralListProvider.swift index 1cb9598266..126a0b72b6 100644 --- a/Amplify/Categories/DataStore/Model/Collection/ArrayLiteralListProvider.swift +++ b/Amplify/Categories/DataStore/Model/Lazy/ArrayLiteralListProvider.swift @@ -46,22 +46,3 @@ public struct ArrayLiteralListProvider: ModelListProvider { nil) } } - -// MARK: - SingleModelProvider - -public struct DefaultModelProvider: ModelProvider { - - let element: Element? - public init(element: Element? = nil) { - self.element = element - } - - public func load() async throws -> Element? { - return element - } - - public func getState() -> ModelProviderState { - return .loaded(element) - } - -} diff --git a/Amplify/Categories/DataStore/Model/Lazy/DefaultModelProvider.swift b/Amplify/Categories/DataStore/Model/Lazy/DefaultModelProvider.swift new file mode 100644 index 0000000000..57bf07365e --- /dev/null +++ b/Amplify/Categories/DataStore/Model/Lazy/DefaultModelProvider.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// MARK: - DefaultModelProvider + +public struct DefaultModelProvider: ModelProvider { + + let element: Element? + public init(element: Element? = nil) { + self.element = element + } + + public func load() async throws -> Element? { + return element + } + + public func getState() -> ModelProviderState { + return .loaded(element) + } + +} diff --git a/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift b/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift new file mode 100644 index 0000000000..44f893ef84 --- /dev/null +++ b/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift @@ -0,0 +1,99 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +public class LazyModel: Codable, LazyModelMarker { + + /// Represents the data state of the `LazyModel`. + enum LoadedState { + case notLoaded + case loaded(Element?) + } + var loadedState: LoadedState + + /// The provider for fulfilling list behaviors + let modelProvider: AnyModelProvider + + public internal(set) var element: Element? { + get { + switch loadedState { + case .notLoaded: + return nil + case .loaded(let element): + return element + } + } + set { + switch loadedState { + case .loaded: + Amplify.log.error(""" + There is an attempt to set an already lazy model. The existing data will not be overwritten + """) + return + case .notLoaded: + loadedState = .loaded(newValue) + } + } + } + public init(modelProvider: AnyModelProvider) { + self.modelProvider = modelProvider + switch self.modelProvider.getState() { + case .loaded(let element): + self.loadedState = .loaded(element) + case .notLoaded: + self.loadedState = .notLoaded + } + } + + public convenience init(element: Element? = nil) { + let modelProvider = DefaultModelProvider(element: element).eraseToAnyModelProvider() + self.init(modelProvider: modelProvider) + } + + required convenience public init(from decoder: Decoder) throws { + for modelDecoder in ModelProviderRegistry.decoders.get() { + if modelDecoder.shouldDecode(modelType: Element.self, decoder: decoder) { + let modelProvider = try modelDecoder.makeModelProvider(modelType: Element.self, decoder: decoder) + self.init(modelProvider: modelProvider) + return + } + } + let json = try JSONValue(from: decoder) + if case .object = json { + let element = try Element(from: decoder) + self.init(element: element) + } else { + self.init() + } + } + + + public func encode(to encoder: Encoder) throws { + switch loadedState { + case .notLoaded: + break + // try Element.encode(to: encoder) + case .loaded(let element): + try element.encode(to: encoder) + } + } + + // MARK: - APIs + + public func get() async throws -> Element? { + switch loadedState { + case .notLoaded: + let element = try await modelProvider.load() + self.loadedState = .loaded(element) + return element + case .loaded(let element): + return element + } + } +} diff --git a/Amplify/Categories/DataStore/Model/Collection/List+Combine.swift b/Amplify/Categories/DataStore/Model/Lazy/List+Combine.swift similarity index 100% rename from Amplify/Categories/DataStore/Model/Collection/List+Combine.swift rename to Amplify/Categories/DataStore/Model/Lazy/List+Combine.swift diff --git a/Amplify/Categories/DataStore/Model/Collection/List+LazyLoad.swift b/Amplify/Categories/DataStore/Model/Lazy/List+LazyLoad.swift similarity index 100% rename from Amplify/Categories/DataStore/Model/Collection/List+LazyLoad.swift rename to Amplify/Categories/DataStore/Model/Lazy/List+LazyLoad.swift diff --git a/Amplify/Categories/DataStore/Model/Collection/List+Model.swift b/Amplify/Categories/DataStore/Model/Lazy/List+Model.swift similarity index 69% rename from Amplify/Categories/DataStore/Model/Collection/List+Model.swift rename to Amplify/Categories/DataStore/Model/Lazy/List+Model.swift index 6b99088403..b01f7177a0 100644 --- a/Amplify/Categories/DataStore/Model/Collection/List+Model.swift +++ b/Amplify/Categories/DataStore/Model/Lazy/List+Model.swift @@ -8,104 +8,6 @@ import Foundation import Combine -public class LazyModel: Codable { - /// Represents the data state of the `List`. - enum LoadedState { - case notLoaded - case loaded(Element?) - } - var loadedState: LoadedState - - /// The provider for fulfilling list behaviors - let modelProvider: AnyModelProvider - - public init(modelProvider: AnyModelProvider) { - self.modelProvider = modelProvider - switch self.modelProvider.getState() { - case .loaded(let element): - self.loadedState = .loaded(element) - case .notLoaded: - self.loadedState = .notLoaded - } - } - - public convenience init(element: Element? = nil) { - let modelProvider = DefaultModelProvider(element: element).eraseToAnyModelProvider() - self.init(modelProvider: modelProvider) - } - - required convenience public init(from decoder: Decoder) throws { - for modelDecoder in ModelProviderRegistry.decoders.get() { - if modelDecoder.shouldDecode(modelType: Element.self, decoder: decoder) { - let modelProvider = try modelDecoder.makeModelProvider(modelType: Element.self, decoder: decoder) - self.init(modelProvider: modelProvider) - return - } - } - let json = try JSONValue(from: decoder) - if case .object = json { - let element = try Element(from: decoder) - self.init(element: element) - } else { - self.init() - } - } - - - public func encode(to encoder: Encoder) throws { - switch loadedState { - case .notLoaded: - break - // try Element.encode(to: encoder) - case .loaded(let element): - try element.encode(to: encoder) - } - } - - // MARK: - APIs - - public func get() async throws -> Element? { - switch loadedState { - case .notLoaded: - return nil - case .loaded(let element): - return element - } - } -} - -public struct AnyModelProvider: ModelProvider { - - private let loadAsync: () async throws -> Element? - private let getStateClosure: () -> ModelProviderState - - public init(provider: Provider) where Provider.Element == Self.Element { - self.loadAsync = provider.load - self.getStateClosure = provider.getState - } - public func load() async throws -> Element? { - try await loadAsync() - } - - public func getState() -> ModelProviderState { - getStateClosure() - } -} - -public protocol ModelProvider { - associatedtype Element: Model - - func load() async throws -> Element? - - func getState() -> ModelProviderState - -} - -public enum ModelProviderState { - case notLoaded(id: String, field: String) - case loaded(Element?) -} - /// `List` is a custom `Collection` that is capable of loading records from a data source. This is especially /// useful when dealing with Model associations that need to be lazy loaded. Lazy loading is performed when you access /// the `Collection` methods by retrieving the data from the underlying data source and then stored into this object, diff --git a/Amplify/Categories/DataStore/Model/Collection/List+Pagination.swift b/Amplify/Categories/DataStore/Model/Lazy/List+Pagination.swift similarity index 100% rename from Amplify/Categories/DataStore/Model/Collection/List+Pagination.swift rename to Amplify/Categories/DataStore/Model/Lazy/List+Pagination.swift diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift index fa9bf17a57..1b281b572b 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift @@ -79,7 +79,7 @@ public struct AppSyncModelDecoder: ModelProviderDecoder { static func makeAppSyncModelProvider(modelType: ModelType.Type, decoder: Decoder) throws -> AppSyncModelProvider? { if let model = try? ModelType.init(from: decoder) { - return try AppSyncModelProvider(model: model) + return AppSyncModelProvider(model: model) } else if let metadata = try? AppSyncPartialModelMetadata.init(from: decoder) { return AppSyncModelProvider(metadata: metadata) } diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift index a1e5f3d5ab..99287df2b0 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift @@ -9,52 +9,44 @@ import Foundation import Amplify import AWSPluginsCore -public class AppSyncModelProvider: ModelProvider { +public class AppSyncModelProvider: ModelProvider { let apiName: String? enum LoadedState { - case notLoaded(identifier: String, - field: String) - case loaded(element: Element?) + case notLoaded(identifier: String) + case loaded(model: ModelType?) } var loadedState: LoadedState - - // init(AppSyncModelPayload) creates a loaded provider - convenience init(model: Element?) throws { - self.init(element: model) - } - // init(AppSyncModelMetadata) creates a notLoaded provider convenience init(metadata: AppSyncPartialModelMetadata) { self.init(identifier: metadata.identifier, - field: metadata.field, apiName: metadata.apiName) } - // Internal initializer for a loaded state - init(element: Element?) { - self.loadedState = .loaded(element: element) + // Initializer for a loaded state + init(model: ModelType?) { + self.loadedState = .loaded(model: model) self.apiName = nil } - // Internal initializer for not loaded state - init(identifier: String, field: String, apiName: String? = nil) { - self.loadedState = .notLoaded(identifier: identifier, field: field) + // Initializer for not loaded state + init(identifier: String, apiName: String? = nil) { + self.loadedState = .notLoaded(identifier: identifier) self.apiName = apiName } // MARK: - APIs - public func load() async throws -> Element? { + public func load() async throws -> ModelType? { switch loadedState { - case .notLoaded(let identifier, _): - let request = GraphQLRequest.getQuery(responseType: Element.self, - modelSchema: Element.schema, + case .notLoaded(let identifier): + let request = GraphQLRequest.getQuery(responseType: ModelType.self, + modelSchema: ModelType.schema, identifier: identifier, apiName: apiName) do { @@ -82,12 +74,12 @@ public class AppSyncModelProvider: ModelProvider { } } - public func getState() -> ModelProviderState { + public func getState() -> ModelProviderState { switch loadedState { - case .notLoaded(let id, let field): - return .notLoaded(id: id, field: field) - case .loaded(let element): - return .loaded(element) + case .notLoaded(let identifier): + return .notLoaded(identifier: identifier) + case .loaded(let model): + return .loaded(model) } } } diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListResponse.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListResponse.swift index 058d30e60e..7f83ab4ce7 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListResponse.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListResponse.swift @@ -48,36 +48,3 @@ extension AppSyncListResponse { } } - -// MARK: - AppSyncModelResponse -// -//struct AppSyncModelResponse: Codable { -// -// public let item: Element? -// -// init(item: Element?) { -// self.item = item -// } -//} -// -//extension AppSyncModelResponse { -// static func initWithMetadata(type: Element.Type, -// graphQLData: JSONValue, -// apiName: String?) throws -> AppSyncModelResponse { -// -// var element: Element? = nil -// if case let .object = graphQLData { -// let jsonObjectWithMetadata = AppSyncModelMetadataUtils.addMetadata(toModel: graphQLData, apiName: apiName) -// -// let encoder = JSONEncoder() -// encoder.dateEncodingStrategy = ModelDateFormatting.encodingStrategy -// let decoder = JSONDecoder() -// decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy -// let serializedJSON = try encoder.encode(jsonObjectWithMetadata) -// element = try decoder.decode(type, from: serializedJSON) -// } -// -// return AppSyncModelResponse(item: element) -// } -// -//} diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift index ba147a2c7b..9928ccd14d 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift @@ -18,7 +18,6 @@ public struct AppSyncModelMetadata: Codable { /// Metadata that contains partial information of a model public struct AppSyncPartialModelMetadata: Codable { let identifier: String - let field: String let apiName: String? } @@ -130,17 +129,14 @@ public struct AppSyncModelMetadataUtils { guard case .object(let modelObject) = modelJSON else { return nil } - - guard case .string(let modelName) = modelObject["__typename"] else { - return nil - } + // TODO: This should be based on the number of fields that make up the identifier + __typename guard modelObject.count == 2 else { return nil } if case .string(let id) = modelObject["id"] { - return AppSyncPartialModelMetadata(identifier: id, field: modelName, apiName: apiName) + return AppSyncPartialModelMetadata(identifier: id, apiName: apiName) } return nil diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Operation/AWSRESTOperationTests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Operation/AWSRESTOperationTests.swift index 555e0f9622..6162d63898 100644 --- a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Operation/AWSRESTOperationTests.swift +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Operation/AWSRESTOperationTests.swift @@ -32,6 +32,7 @@ class AWSRESTOperationTests: OperationTestBase { throw XCTSkip("Not yet implemented") } + // TODO: Fix this test func testGetReturnsOperation() throws { try setUpPlugin(endpointType: .rest) diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift index 4058a80678..a2f0261ad4 100644 --- a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift @@ -47,7 +47,7 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { (\"post\").", underlyingError: nil))" ``` */ - func testGetChildModel() throws { + func testQueryComment() throws { let request = GraphQLRequest(document: "", responseType: LazyChildComment4V2.self, decodePath: "getLazyChildComment4V2") @@ -65,7 +65,7 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { XCTAssertEqual(result.content, "content") } - func testGetParentModel() throws { + func testQueryPost() throws { let request = GraphQLRequest(document: "", responseType: LazyParentPost4V2.self, decodePath: "getLazyParentPost4V2") @@ -83,7 +83,7 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { XCTAssertEqual(result.title, "title") } - func testListChildModel() throws { + func testQueryListComments() throws { let request = GraphQLRequest>(document: "", responseType: List.self, decodePath: "listLazyChildComment4V2") @@ -112,7 +112,7 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { XCTAssertNotNil(comment2) } - func testListParentModel() throws { + func testQueryListPosts() throws { let request = GraphQLRequest>(document: "", responseType: List.self, decodePath: "listLazyParentPost4V2") @@ -141,7 +141,7 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { XCTAssertNotNil(post2) } - func testPostHasLazyLoadComments() throws { + func testPostHasLazyLoadedComments() throws { let request = GraphQLRequest.get(LazyParentPost4V2.self, byId: "id") let documentStringValue = """ query GetLazyParentPost4V2($id: ID!) { @@ -334,9 +334,8 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { return } switch post.modelProvider.getState() { - case .notLoaded(let id, let field): + case .notLoaded(let id): XCTAssertEqual(id, "postId") - XCTAssertEqual(field, "LazyParentPost4V2") case .loaded: XCTFail("Should be not loaded") } @@ -487,7 +486,6 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { case .loaded(let post): XCTAssertEqual(post!.id, "postId2") XCTAssertEqual(post!.title, "title2") - print("\(post!.comments?.listProvider)") } } @@ -544,13 +542,13 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { } switch post1.modelProvider.getState() { - case .notLoaded(let identifier, _): + case .notLoaded(let identifier): XCTAssertEqual(identifier, "postId1") case .loaded: XCTFail("Should be not loaded") } switch post2.modelProvider.getState() { - case .notLoaded(let identifier, _): + case .notLoaded(let identifier): XCTAssertEqual(identifier, "postId2") case .loaded: XCTFail("Should be not loaded") diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift index 053a5b1030..b59bc079ea 100644 --- a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift @@ -44,7 +44,7 @@ class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase { XCTAssertEqual(result, "{\"id\":\"id\"}") } - func testGetChildModel() throws { + func testQueryComment() throws { let request = GraphQLRequest(document: "", responseType: ChildComment4V2.self, decodePath: "getChildComment4V2") @@ -62,7 +62,7 @@ class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase { XCTAssertEqual(result.content, "content") } - func testGetParentModel() throws { + func testQueryPost() throws { let request = GraphQLRequest(document: "", responseType: ParentPost4V2.self, decodePath: "getParentPost4V2") @@ -80,7 +80,7 @@ class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase { XCTAssertEqual(result.title, "title") } - func testListChildModel() throws { + func testQueryListComments() throws { let request = GraphQLRequest>(document: "", responseType: List.self, decodePath: "listChildComment4V2") @@ -109,7 +109,7 @@ class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase { XCTAssertNotNil(comment2) } - func testListParentModel() throws { + func testQueryListPosts() throws { let request = GraphQLRequest>(document: "", responseType: List.self, decodePath: "listParentPost4V2") @@ -138,7 +138,7 @@ class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase { XCTAssertNotNil(post2) } - func testPostHasLazyLoadComments() throws { + func testPostHasLazyLoadedComments() throws { let request = GraphQLRequest.get(ParentPost4V2.self, byId: "id") let documentStringValue = """ query GetParentPost4V2($id: ID!) { diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderTests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderTests.swift index 949232cae1..6d9144880b 100644 --- a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderTests.swift +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderTests.swift @@ -20,8 +20,6 @@ class GraphQLResponseDecoderTests: XCTestCase { ModelRegistry.register(modelType: SimpleModel.self) ModelRegistry.register(modelType: Post4.self) ModelRegistry.register(modelType: Comment4.self) - ModelRegistry.register(modelType: ParentPost4V2.self) - ModelRegistry.register(modelType: ChildComment4V2.self) ModelListDecoderRegistry.registerDecoder(AppSyncListDecoder.self) ModelProviderRegistry.registerDecoder(AppSyncModelDecoder.self) diff --git a/AmplifyPlugins/Core/AWSPluginsCore/Model/Support/Model+GraphQL.swift b/AmplifyPlugins/Core/AWSPluginsCore/Model/Support/Model+GraphQL.swift index e6e1154423..72e526dd59 100644 --- a/AmplifyPlugins/Core/AWSPluginsCore/Model/Support/Model+GraphQL.swift +++ b/AmplifyPlugins/Core/AWSPluginsCore/Model/Support/Model+GraphQL.swift @@ -185,6 +185,9 @@ extension Model { } else if let optionalModel = value as? Model?, let modelValue = optionalModel { return modelValue.identifier(schema: modelSchema).values + } else if let lazyModelValue = value as? (any LazyModelMarker) { + print("FOUND A LAZY MODEL \(lazyModelValue)") + //return lazyModelValue.element?.identifier } else if let value = value as? [String: JSONValue] { var primaryKeyValues = [Persistable]() for field in modelSchema.primaryKey.fields { diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift index acc1d1f13b..962550379a 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift @@ -214,6 +214,7 @@ extension AWSDataStorePlugin: DataStoreBaseBehavior { predicate: predicate, sort: sortInput, paginationInput: paginationInput, + eagerLoad: true, completion: completion) } @@ -495,7 +496,8 @@ extension AWSDataStorePlugin: DataStoreBaseBehavior { storageEngine.query(MutationSyncMetadata.self, predicate: metadata.id == metadataId, sort: nil, - paginationInput: .firstResult) { + paginationInput: .firstResult, + eagerLoad: true) { do { let result = try $0.get() let syncMetadata = try result.unique() diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreSubscribeBehavior.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreSubscribeBehavior.swift index 19acdea9cb..3f17a2bbcc 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreSubscribeBehavior.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreSubscribeBehavior.swift @@ -51,4 +51,5 @@ extension AWSDataStorePlugin: DataStoreSubscribeBehavior { dataStoreStatePublisher: dataStoreStateSubject.eraseToAnyPublisher()) return taskRunner.sequence } + } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin.swift index 6f210fb8e6..63c3d34a2f 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin.swift @@ -114,6 +114,7 @@ final public class AWSDataStorePlugin: DataStoreCategoryPlugin { } resolveSyncEnabled() ModelListDecoderRegistry.registerDecoder(DataStoreListDecoder.self) + ModelProviderRegistry.registerDecoder(DataStoreModelDecoder.self) } /// Initializes the underlying storage engine diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreListProvider.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreListProvider.swift index 73ee1ae3fd..9047bc73d2 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreListProvider.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreListProvider.swift @@ -45,8 +45,8 @@ public class DataStoreListProvider: ModelListProvider { public func getState() -> ModelListProviderState { switch loadedState { - case .notLoaded: - return .notLoaded + case .notLoaded(let associatedId, let associatedField): + return .notLoaded(associatedId: associatedId, associatedField: associatedField) case .loaded(let elements): return .loaded(elements) } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift new file mode 100644 index 0000000000..074cdd4e15 --- /dev/null +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift @@ -0,0 +1,46 @@ +// +// File.swift +// +// +// Created by Law, Michael on 8/24/22. +// + +import Foundation +import Amplify + +public struct DataStoreModelDecoder: ModelProviderDecoder { + public static func shouldDecode(modelType: ModelType.Type, decoder: Decoder) -> Bool { + guard let json = try? JSONValue(from: decoder) else { + return false + } + // TODO: This needs to be more strict once we have more than one decoder running + // without any sort of priority + // check if it has the single field + + return true + } + + public static func makeModelProvider(modelType: ModelType.Type, + decoder: Decoder) throws -> AnyModelProvider { + + if let provider = try getDataStoreModelProvider(modelType: modelType, decoder: decoder) { + return provider.eraseToAnyModelProvider() + } + + return DefaultModelProvider(element: nil).eraseToAnyModelProvider() + } + + static func getDataStoreModelProvider(modelType: ModelType.Type, + decoder: Decoder) throws -> DataStoreModelProvider? { + let json = try? JSONValue(from: decoder) + + // Attempt to decode to the entire model as a loaded model provider + if let model = try? ModelType.init(from: decoder) { + return DataStoreModelProvider(model: model) + } else if case .string(let identifier) = json { // A not loaded model provider + return DataStoreModelProvider(identifier: identifier) + } + + return nil + } +} diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift new file mode 100644 index 0000000000..e13e92f057 --- /dev/null +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift @@ -0,0 +1,50 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify +import Combine + +public class DataStoreModelProvider: ModelProvider { + + enum LoadedState { + case notLoaded(identifier: String) + case loaded(model: ModelType?) + } + + var loadedState: LoadedState + + init(model: ModelType?) { + self.loadedState = .loaded(model: model) + } + + init(identifier: String) { + self.loadedState = .notLoaded(identifier: identifier) + } + + // MARK: - APIs + + public func load() async throws -> ModelType? { + switch loadedState { + case .notLoaded(let identifier): + let model = try await Amplify.DataStore.query(ModelType.self, byId: identifier) + self.loadedState = .loaded(model: model) + return model + case .loaded(let model): + return model + } + } + + public func getState() -> ModelProviderState { + switch loadedState { + case .notLoaded(let identifier): + return .notLoaded(identifier: identifier) + case .loaded(let model): + return .loaded(model) + } + } +} diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/CascadeDeleteOperation.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/CascadeDeleteOperation.swift index 3edd5067fb..04f62e18b8 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/CascadeDeleteOperation.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/CascadeDeleteOperation.swift @@ -103,7 +103,8 @@ public class CascadeDeleteOperation: AsynchronousOperation { modelSchema: self.modelSchema, predicate: self.deleteInput.predicate, sort: nil, - paginationInput: nil) { result in + paginationInput: nil, + eagerLoad: true) { result in continuation.resume(returning: result) } } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift index 99bd47adb6..e1a77222f7 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift @@ -43,6 +43,7 @@ protocol ModelStorageBehavior { predicate: QueryPredicate?, sort: [QuerySortDescriptor]?, paginationInput: QueryPaginationInput?, + eagerLoad: Bool, completion: DataStoreCallback<[M]>) func query(_ modelType: M.Type, @@ -50,6 +51,7 @@ protocol ModelStorageBehavior { predicate: QueryPredicate?, sort: [QuerySortDescriptor]?, paginationInput: QueryPaginationInput?, + eagerLoad: Bool, completion: DataStoreCallback<[M]>) } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift index 85227d154e..f213c7c0f3 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift @@ -106,7 +106,9 @@ extension Model { // Check if it is a Model or json object. if let associatedModelValue = value as? Model { return associatedModelValue.identifier - + } else if let associatedLazyModel = value as? (any LazyModelMarker) { + print("FOUND A LAZY MODEL \(associatedLazyModel) id: \(associatedLazyModel.element?.identifier)") + return associatedLazyModel.element?.identifier } else if let associatedModelJSON = value as? [String: JSONValue] { return associatedPrimaryKeyValue(fromJSON: associatedModelJSON, associatedModelSchema: associatedModelSchema) diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Select.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Select.swift index f51407e5cd..9a0b9e909a 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Select.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Select.swift @@ -22,7 +22,8 @@ struct SelectStatementMetadata { static func metadata(from modelSchema: ModelSchema, predicate: QueryPredicate? = nil, sort: [QuerySortDescriptor]? = nil, - paginationInput: QueryPaginationInput? = nil) -> SelectStatementMetadata { + paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true) -> SelectStatementMetadata { let rootNamespace = "root" let fields = modelSchema.columns let tableName = modelSchema.name @@ -33,7 +34,7 @@ struct SelectStatementMetadata { } // eager load many-to-one/one-to-one relationships - let joinStatements = joins(from: modelSchema) + let joinStatements = joins(from: modelSchema, eagerLoad: eagerLoad) columns += joinStatements.columns columnMapping.merge(joinStatements.columnMapping) { _, new in new } @@ -86,10 +87,15 @@ struct SelectStatementMetadata { /// /// Implementation note: this should be revisited once we define support /// for explicit `eager` vs `lazy` associations. - private static func joins(from schema: ModelSchema) -> JoinStatement { + private static func joins(from schema: ModelSchema, eagerLoad: Bool = true) -> JoinStatement { var columns: [String] = [] var joinStatements: [String] = [] var columnMapping: ColumnMapping = [:] + guard eagerLoad == true else { + return JoinStatement(columns: columns, + statements: joinStatements, + columnMapping: columnMapping) + } func visitAssociations(node: ModelSchema, namespace: String = "root") { for foreignKey in node.foreignKeys { @@ -142,12 +148,14 @@ struct SelectStatement: SQLStatement { init(from modelSchema: ModelSchema, predicate: QueryPredicate? = nil, sort: [QuerySortDescriptor]? = nil, - paginationInput: QueryPaginationInput? = nil) { + paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true) { self.modelSchema = modelSchema self.metadata = .metadata(from: modelSchema, predicate: predicate, sort: sort, - paginationInput: paginationInput) + paginationInput: paginationInput, + eagerLoad: eagerLoad) } var stringValue: String { diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift index 5a9b024aa3..a9aca91b46 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift @@ -41,7 +41,8 @@ protocol StatementModelConvertible { /// - Returns: an array of `Model` of the specified type func convert(to modelType: M.Type, withSchema modelSchema: ModelSchema, - using statement: SelectStatement) throws -> [M] + using statement: SelectStatement, + eagerLoad: Bool) throws -> [M] } @@ -52,29 +53,44 @@ extension Statement: StatementModelConvertible { Amplify.Logging.logger(forCategory: .dataStore) } + func convert(to modelType: M.Type, withSchema modelSchema: ModelSchema, - using statement: SelectStatement) throws -> [M] { + using statement: SelectStatement, + eagerLoad: Bool = true) throws -> [M] { + let elements: [ModelValues] = try self.convertToModelValues(to: modelType, + withSchema: modelSchema, + using: statement, + eagerLoad: eagerLoad) + let values: ModelValues = ["elements": elements] + let result: StatementResult = try StatementResult.from(dictionary: values) + return result.elements + } + + func convertToModelValues(to modelType: M.Type, + withSchema modelSchema: ModelSchema, + using statement: SelectStatement, + eagerLoad: Bool = true) throws -> [ModelValues] { var elements: [ModelValues] = [] // parse each row of the result let iter = makeIterator() while let row = try iter.failableNext() { - let modelDictionary = try convert(row: row, withSchema: modelSchema, using: statement) + let modelDictionary = try convert(row: row, withSchema: modelSchema, using: statement, eagerLoad: eagerLoad) elements.append(modelDictionary) } - - let values: ModelValues = ["elements": elements] - let result: StatementResult = try StatementResult.from(dictionary: values) - return result.elements + return elements } - + func convert(row: Element, withSchema modelSchema: ModelSchema, - using statement: SelectStatement) throws -> ModelValues { + using statement: SelectStatement, + eagerLoad: Bool = true) throws -> ModelValues { let columnMapping = statement.metadata.columnMapping let modelDictionary = ([:] as ModelValues).mutableCopy() var skipColumns = Set() + var lazyLoadColumnValues = [(String, Binding?)]() + print(row) for (index, value) in row.enumerated() { let column = columnNames[index] guard let (schema, field) = columnMapping[column] else { @@ -82,9 +98,10 @@ extension Statement: StatementModelConvertible { A column named \(column) was found in the result set but no field on \(modelSchema.name) could be found with that name and it will be ignored. """) + lazyLoadColumnValues.append((column, value)) continue } - + let modelValue = try SQLiteModelValueConverter.convertToSource( from: value, fieldType: field.type @@ -155,10 +172,25 @@ extension Statement: StatementModelConvertible { // modelDictionary["post"]["blog"] = nil let sortedColumns = skipColumns.sorted(by: { $0.count > $1.count }) for skipColumn in sortedColumns { + print("Skipping column: \(skipColumn)") modelDictionary.updateValue(nil, forKeyPath: skipColumn) } modelDictionary["__typename"] = modelSchema.name + // `lazyloadColumnValues` are all foreign keys that can be added to the object for lazy loading + // Once lazy loading is enabled, this `lazyloadColumnValues` will be populated. + if !eagerLoad { + for lazyLoadColumnValue in lazyLoadColumnValues { + let foreignColumnName = lazyLoadColumnValue.0 + if let foreignModel = modelSchema.foreignKeys.first(where: { modelField in + modelField.sqlName == foreignColumnName + }) { + modelDictionary[foreignModel.name] = lazyLoadColumnValue.1 + } + } + } + + // swiftlint:disable:next force_cast return modelDictionary as! ModelValues } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift index 72856bc9fd..b228f67d7d 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift @@ -285,12 +285,14 @@ final class SQLiteStorageEngineAdapter: StorageEngineAdapter { predicate: QueryPredicate? = nil, sort: [QuerySortDescriptor]? = nil, paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true, completion: DataStoreCallback<[M]>) { query(modelType, modelSchema: modelType.schema, predicate: predicate, sort: sort, paginationInput: paginationInput, + eagerLoad: eagerLoad, completion: completion) } @@ -299,6 +301,7 @@ final class SQLiteStorageEngineAdapter: StorageEngineAdapter { predicate: QueryPredicate? = nil, sort: [QuerySortDescriptor]? = nil, paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true, completion: DataStoreCallback<[M]>) { guard let connection = connection else { completion(.failure(DataStoreError.nilSQLiteConnection())) @@ -308,11 +311,13 @@ final class SQLiteStorageEngineAdapter: StorageEngineAdapter { let statement = SelectStatement(from: modelSchema, predicate: predicate, sort: sort, - paginationInput: paginationInput) + paginationInput: paginationInput, + eagerLoad: eagerLoad) let rows = try connection.prepare(statement.stringValue).run(statement.variables) let result: [M] = try rows.convert(to: modelType, withSchema: modelSchema, - using: statement) + using: statement, + eagerLoad: eagerLoad) completion(.success(result)) } catch { completion(.failure(causedBy: error)) diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/StorageEngine.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/StorageEngine.swift index 26560a2962..34d745f32a 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/StorageEngine.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/StorageEngine.swift @@ -270,12 +270,14 @@ final class StorageEngine: StorageEngineBehavior { predicate: QueryPredicate?, sort: [QuerySortDescriptor]?, paginationInput: QueryPaginationInput?, + eagerLoad: Bool = true, completion: (DataStoreResult<[M]>) -> Void) { return storageAdapter.query(modelType, modelSchema: modelSchema, predicate: predicate, sort: sort, paginationInput: paginationInput, + eagerLoad: eagerLoad, completion: completion) } @@ -283,12 +285,14 @@ final class StorageEngine: StorageEngineBehavior { predicate: QueryPredicate? = nil, sort: [QuerySortDescriptor]? = nil, paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true, completion: DataStoreCallback<[M]>) { query(modelType, modelSchema: modelType.schema, predicate: predicate, sort: sort, paginationInput: paginationInput, + eagerLoad: eagerLoad, completion: completion) } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Subscribe/DataStoreObserveQueryOperation.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Subscribe/DataStoreObserveQueryOperation.swift index 804fd211ed..4aa3b0b462 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Subscribe/DataStoreObserveQueryOperation.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Subscribe/DataStoreObserveQueryOperation.swift @@ -197,6 +197,7 @@ class ObserveQueryTaskRunner: InternalTaskRunner, InternalTaskAsyncThr predicate: predicate, sort: sortInput, paginationInput: nil, + eagerLoad: true, completion: { queryResult in switch queryResult { case .success(let queriedModels): diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift index 43cbf9fd32..8d58678059 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift @@ -23,7 +23,8 @@ extension AWSMutationDatabaseAdapter: MutationEventSource { storageAdapter.query(MutationEvent.self, predicate: predicate, sort: [sort], - paginationInput: nil) { result in + paginationInput: nil, + eagerLoad: true) { result in switch result { case .failure(let dataStoreError): completion(.failure(dataStoreError)) diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift index 6332380c9f..5473c3b9e9 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift @@ -22,7 +22,8 @@ final class MutationEventClearState { storageAdapter.query(MutationEvent.self, predicate: predicate, sort: [sort], - paginationInput: nil) { result in + paginationInput: nil, + eagerLoad: true) { result in switch result { case .failure(let dataStoreError): log.error("Failed on clearStateOutgoingMutations: \(dataStoreError)") diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift index f66eadd88b..ac9308a492 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift @@ -331,7 +331,8 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { storageAdapter.query(MutationEvent.self, predicate: predicate, sort: nil, - paginationInput: nil) { result in + paginationInput: nil, + eagerLoad: true) { result in switch result { case .success(let events): self.dispatchOutboxStatusEvent(isEmpty: events.isEmpty) diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/Support/MutationEvent+Query.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/Support/MutationEvent+Query.swift index 968970efdd..5dc808fb15 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/Support/MutationEvent+Query.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/Support/MutationEvent+Query.swift @@ -39,7 +39,8 @@ extension MutationEvent { storageAdapter.query(MutationEvent.self, predicate: final, sort: [sort], - paginationInput: nil) { result in + paginationInput: nil, + eagerLoad: true) { result in continuation.resume(with: result) } } diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Core/SQLStatementTests.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Core/SQLStatementTests.swift index 19073ed43c..73e631db0c 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Core/SQLStatementTests.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Core/SQLStatementTests.swift @@ -967,6 +967,14 @@ class SQLStatementTests: XCTestCase { where 1 = 1 and "root"."@@postForeignKey" = ? """ + let noJoin = """ + select + "root"."@@primaryKey" as "@@primaryKey", "root"."id" as "id", "root"."content" as "content", + "root"."createdAt" as "createdAt", "root"."updatedAt" as "updatedAt", "root"."@@postForeignKey" as "@@postForeignKey" + from "CommentWithCompositeKey" as "root" + where 1 = 1 + and "root"."@@postForeignKey" = ? + """ XCTAssertEqual(statement.stringValue, expectedStatement) } diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsBase.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsBase.swift index 3c142f49d1..6e7cb5a448 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsBase.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsBase.swift @@ -54,6 +54,14 @@ class StorageEngineTestsBase: XCTestCase { return saveResult } + func saveAsync(_ model: M) async throws -> M { + try await withCheckedThrowingContinuation { continuation in + storageEngine.save(model) { sResult in + continuation.resume(with: sResult) + } + } + } + func querySingleModelSynchronous(modelType: M.Type, predicate: QueryPredicate) -> DataStoreResult { let result = queryModelSynchronous(modelType: modelType, predicate: predicate) @@ -102,6 +110,32 @@ class StorageEngineTestsBase: XCTestCase { } return queryResult } + + func queryAsync(_ modelType: M.Type, + byIdentifier identifier: String, + eagerLoad: Bool = true) async throws -> M? { + let predicate: QueryPredicate = field("id").eq(identifier) + return try await queryAsync(modelType, predicate: predicate, eagerLoad: eagerLoad).first + } + + func queryAsync(_ modelType: M.Type, predicate: QueryPredicate? = nil, eagerLoad: Bool = true) async throws -> [M] { + try await withCheckedThrowingContinuation { continuation in + storageEngine.query(modelType, predicate: predicate, eagerLoad: eagerLoad) { qResult in + continuation.resume(with: qResult) + } + } + } + + func queryStorageAdapter(_ modelType: M.Type, + byIdentifier identifier: String, + eagerLoad: Bool = true) async throws -> M? { + let predicate: QueryPredicate = field("id").eq(identifier) + return try await withCheckedThrowingContinuation { continuation in + storageAdapter.query(modelType, predicate: predicate) { result in + continuation.resume(with: result) + } + }.first + } func deleteModelSynchronousOrFailOtherwise(modelType: M.Type, withId id: String, @@ -145,4 +179,17 @@ class StorageEngineTestsBase: XCTestCase { } return deleteResult } + + func deleteAsync(modelType: M.Type, + withId id: String, + where predicate: QueryPredicate? = nil) async throws -> M? { + try await withCheckedThrowingContinuation { continuation in + storageEngine.delete(modelType, + modelSchema: modelType.schema, + withId: id, + condition: predicate) { dResult in + continuation.resume(with: dResult) + } + } + } } diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsHasMany.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsHasMany.swift index e26581bd3d..daa980866b 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsHasMany.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsHasMany.swift @@ -137,7 +137,7 @@ class StorageEngineTestsHasMany: StorageEngineTestsBase { * make two queries: One query with 950 expressions, and one query with 1 expression. * */ - func testStressDeleteTopLevelMultiLevelHasManyRelationship() { + func testStressDeleteTopLeveleagerLoadHasManyRelationship() { let iterations = 500 let numberOfMenus = iterations * 2 let numberOfDishes = iterations * 4 diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift new file mode 100644 index 0000000000..a2de0d75d4 --- /dev/null +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift @@ -0,0 +1,530 @@ +// +// StorageEngineTestsLazyPostComment4V2Tests.swift +// +// +// Created by Law, Michael on 8/22/22. +// + + +import Foundation +import SQLite +import XCTest + +@testable import Amplify +@testable import AmplifyTestCommon +@testable import AWSDataStorePlugin + + +final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase { + + override func setUp() { + super.setUp() + Amplify.Logging.logLevel = .warn + + let validAPIPluginKey = "MockAPICategoryPlugin" + let validAuthPluginKey = "MockAuthCategoryPlugin" + do { + connection = try Connection(.inMemory) + storageAdapter = try SQLiteStorageEngineAdapter(connection: connection) + try storageAdapter.setUp(modelSchemas: StorageEngine.systemModelSchemas) + + syncEngine = MockRemoteSyncEngine() + storageEngine = StorageEngine(storageAdapter: storageAdapter, + dataStoreConfiguration: .default, + syncEngine: syncEngine, + validAPIPluginKey: validAPIPluginKey, + validAuthPluginKey: validAuthPluginKey) + + ModelListDecoderRegistry.registerDecoder(DataStoreListDecoder.self) + ModelProviderRegistry.registerDecoder(DataStoreModelDecoder.self) + + ModelRegistry.register(modelType: LazyParentPost4V2.self) + ModelRegistry.register(modelType: LazyChildComment4V2.self) + do { + try storageEngine.setUp(modelSchemas: [LazyParentPost4V2.schema]) + try storageEngine.setUp(modelSchemas: [LazyChildComment4V2.schema]) + } catch { + XCTFail("Failed to setup storage engine") + } + } catch { + XCTFail(String(describing: error)) + return + } + } + + func testQueryComment() async throws { + let comment = LazyChildComment4V2(content: "content") + _ = try await saveAsync(comment) + + guard (try await queryAsync(LazyChildComment4V2.self, + byIdentifier: comment.id)) != nil else { + XCTFail("Failed to query saved comment") + return + } + } + + func testQueryPost() async throws { + let post = LazyParentPost4V2(title: "title") + _ = try await saveAsync(post) + + guard (try await queryAsync(LazyParentPost4V2.self, + byIdentifier: post.id)) != nil else { + XCTFail("Failed to query saved post") + return + } + } + + func testQueryListComments() async throws { + _ = try await saveAsync(LazyChildComment4V2(content: "content")) + _ = try await saveAsync(LazyChildComment4V2(content: "content")) + + let comments = try await queryAsync(LazyChildComment4V2.self) + XCTAssertEqual(comments.count, 2) + } + + func testQueryListPosts() async throws { + _ = try await saveAsync(LazyParentPost4V2(title: "title")) + _ = try await saveAsync(LazyParentPost4V2(title: "title")) + + let comments = try await queryAsync(LazyParentPost4V2.self) + XCTAssertEqual(comments.count, 2) + } + + func testPostHasLazyLoadedComments() async throws { + let post = LazyParentPost4V2(title: "title") + _ = try await saveAsync(post) + let comment = LazyChildComment4V2(content: "content", post: post) + _ = try await saveAsync(comment) + + guard let queriedPost = try await queryAsync(LazyParentPost4V2.self, + byIdentifier: post.id) else { + XCTFail("Failed to query saved post") + return + } + XCTAssertEqual(queriedPost.id, post.id) + guard let comments = queriedPost.comments else { + XCTFail("Failed to get comments from queried post") + return + } + + switch comments.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, post.id) + XCTAssertEqual(associatedField, "post") + case .loaded(let comments): + XCTFail("Should not be loaded") + } + } + + func testCommentHasEagerLoadedPost_InsertUpdateSelect() async throws { + let post = LazyParentPost4V2(id: "postId", title: "title") + _ = try await saveAsync(post) + var comment = LazyChildComment4V2(content: "content", post: post) + + // Insert + let insertStatement = InsertStatement(model: comment, modelSchema: LazyChildComment4V2.schema) + XCTAssertEqual(insertStatement.variables[4] as? String, post.id) + _ = try connection.prepare(insertStatement.stringValue).run(insertStatement.variables) + + // Update + comment.content = "updatedContent" + let updateStatement = UpdateStatement(model: comment, + modelSchema: LazyChildComment4V2.schema, + condition: nil) + _ = try connection.prepare(updateStatement.stringValue).run(updateStatement.variables) + + // Select + let selectStatement = SelectStatement(from: LazyChildComment4V2.schema, + predicate: field("id").eq(comment.id), + sort: nil, + paginationInput: nil, + eagerLoad: true) + let rows = try connection.prepare(selectStatement.stringValue).run(selectStatement.variables) + _ = try rows.convertToModelValues(to: LazyChildComment4V2.self, + withSchema: LazyChildComment4V2.schema, + using: selectStatement) + let comments = try rows.convert(to: LazyChildComment4V2.self, + withSchema: LazyChildComment4V2.schema, + using: selectStatement) + XCTAssertEqual(comments.count, 1) + guard let comment = comments.first else { + XCTFail("Should retrieve single comment") + return + } + + XCTAssertEqual(comment.content, "updatedContent") + guard let eagerLoadedPost = comment.post else { + XCTFail("post should be decoded") + return + } + switch eagerLoadedPost.modelProvider.getState() { + case .notLoaded: + XCTFail("Should have been loaded") + case .loaded(let post): + guard let post = post else { + XCTFail("Loaded with no post") + return + } + XCTAssertEqual(post.id, "postId") + XCTAssertEqual(post.title, "title") + } + } + + func testCommentHasEagerLoadedPost_StorageEngineAdapterSQLite() async throws { + let post = LazyParentPost4V2(id: "postId", title: "title") + _ = try await saveAsync(post) + let comment = LazyChildComment4V2(content: "content", post: post) + _ = try await saveAsync(comment) + + guard let queriedComment = try await queryStorageAdapter(LazyChildComment4V2.self, + byIdentifier: comment.id) else { + XCTFail("Failed to query saved comment") + return + } + XCTAssertEqual(queriedComment.id, comment.id) + guard let eagerLoadedPost = queriedComment.post else { + XCTFail("post should be decoded") + return + } + switch eagerLoadedPost.modelProvider.getState() { + case .notLoaded: + XCTFail("Should have been loaded") + case .loaded(let post): + guard let post = post else { + XCTFail("Loaded with no post") + return + } + XCTAssertEqual(post.id, "postId") + XCTAssertEqual(post.title, "title") + } + } + + // Loading the comments should also eager load the post since `eagerLoad` is true by default. `eagerLoad` + // controls whether nested data is fetched using the SQL join statements. + func testCommentHasEagerLoadedPost_StorageEngine() async throws { + let post = LazyParentPost4V2(id: "postId", title: "title") + _ = try await saveAsync(post) + let comment = LazyChildComment4V2(content: "content", post: post) + _ = try await saveAsync(comment) + + guard let queriedComment = try await queryAsync(LazyChildComment4V2.self, + byIdentifier: comment.id) else { + XCTFail("Failed to query saved comment") + return + } + XCTAssertEqual(queriedComment.id, comment.id) + guard let eagerLoadedPost = queriedComment.post else { + XCTFail("post should be decoded") + return + } + switch eagerLoadedPost.modelProvider.getState() { + case .notLoaded: + XCTFail("Should have been loaded") + case .loaded(let post): + guard let post = post else { + XCTFail("Loaded with no post") + return + } + XCTAssertEqual(post.id, "postId") + XCTAssertEqual(post.title, "title") + } + } + + func testCommentHasLazyLoadedPost_InsertUpdateSelect() async throws { + let eagerLoad = false + let post = LazyParentPost4V2(id: "postId", title: "title") + _ = try await saveAsync(post) + var comment = LazyChildComment4V2(content: "content", post: post) + + // Insert + let insertStatement = InsertStatement(model: comment, modelSchema: LazyChildComment4V2.schema) + XCTAssertEqual(insertStatement.variables[4] as? String, post.id) + _ = try connection.prepare(insertStatement.stringValue).run(insertStatement.variables) + + // Update + comment.content = "updatedContent" + let updateStatement = UpdateStatement(model: comment, + modelSchema: LazyChildComment4V2.schema, + condition: nil) + _ = try connection.prepare(updateStatement.stringValue).run(updateStatement.variables) + + // Select + let selectStatement = SelectStatement(from: LazyChildComment4V2.schema, + predicate: field("id").eq(comment.id), + sort: nil, + paginationInput: nil, + eagerLoad: eagerLoad) + let rows = try connection.prepare(selectStatement.stringValue).run(selectStatement.variables) + let modelJSON = try rows.convertToModelValues(to: LazyChildComment4V2.self, + withSchema: LazyChildComment4V2.schema, + using: selectStatement, + eagerLoad: eagerLoad) + let comments = try rows.convert(to: LazyChildComment4V2.self, + withSchema: LazyChildComment4V2.schema, + using: selectStatement, + eagerLoad: eagerLoad) + XCTAssertEqual(comments.count, 1) + guard let comment = comments.first else { + XCTFail("Should retrieve single comment") + return + } + + XCTAssertEqual(comment.content, "updatedContent") + guard let lazyLoadedPost = comment.post else { + XCTFail("post should be decoded") + return + } + switch lazyLoadedPost.modelProvider.getState() { + case .notLoaded(let id): + XCTAssertEqual(id, "postId") + case .loaded: + XCTFail("Should be not loaded") + } + } + + // Loading the comments should lazy load the post when `eagerLoad` is explicitly set to false. This will stop the + // SQL join statements from being added and only store the + func testCommentHasLazyLoadPost() async throws { + let post = LazyParentPost4V2(id: "postId", title: "title") + _ = try await saveAsync(post) + let comment = LazyChildComment4V2(content: "content", post: post) + _ = try await saveAsync(comment) + + guard let queriedComment = try await queryAsync(LazyChildComment4V2.self, + byIdentifier: comment.id, + eagerLoad: false) else { + XCTFail("Failed to query saved comment") + return + } + XCTAssertEqual(queriedComment.id, comment.id) + guard let lazyLoadedPost = queriedComment.post else { + XCTFail("post should be decoded") + return + } + switch lazyLoadedPost.modelProvider.getState() { + case .notLoaded(let id): + XCTAssertEqual(id, "postId") + case .loaded: + XCTFail("Should be not loaded") + } + } + + func testListCommentHasEagerLoadedPost() async throws { + let post1 = LazyParentPost4V2(id: "postId1", title: "title1") + _ = try await saveAsync(post1) + let comment1 = LazyChildComment4V2(id: "id1", content: "content", post: post1) + _ = try await saveAsync(comment1) + let post2 = LazyParentPost4V2(id: "postId2", title: "title2") + _ = try await saveAsync(post2) + let comment2 = LazyChildComment4V2(id: "id2", content: "content", post: post2) + _ = try await saveAsync(comment2) + + let comments = try await queryAsync(LazyChildComment4V2.self, + eagerLoad: true) + + XCTAssertEqual(comments.count, 2) + guard let comment1 = comments.first(where: { $0.id == "id1" }) else { + XCTFail("Couldn't find comment with `id1`") + return + } + guard let comment2 = comments.first(where: { $0.id == "id2" }) else { + XCTFail("Couldn't find comment with `id2`") + return + } + guard let post1 = comment1.post else { + XCTFail("missing post on comment1") + return + } + guard let post2 = comment2.post else { + XCTFail("missing post on comment2") + return + } + + switch post1.modelProvider.getState() { + case .notLoaded: + XCTFail("Should be loaded") + case .loaded(let post): + XCTAssertEqual(post!.id, "postId1") + XCTAssertEqual(post!.title, "title1") + } + switch post2.modelProvider.getState() { + case .notLoaded: + XCTFail("Should be loaded") + case .loaded(let post): + XCTAssertEqual(post!.id, "postId2") + XCTAssertEqual(post!.title, "title2") + } + } + + func testListCommentHasLazyLoadedPost() async throws { + let post1 = LazyParentPost4V2(id: "postId1", title: "title1") + _ = try await saveAsync(post1) + let comment1 = LazyChildComment4V2(id: "id1", content: "content", post: post1) + _ = try await saveAsync(comment1) + let post2 = LazyParentPost4V2(id: "postId2", title: "title2") + _ = try await saveAsync(post2) + let comment2 = LazyChildComment4V2(id: "id2", content: "content", post: post2) + _ = try await saveAsync(comment2) + + let comments = try await queryAsync(LazyChildComment4V2.self, + eagerLoad: false) + + XCTAssertEqual(comments.count, 2) + guard let comment1 = comments.first(where: { $0.id == "id1" }) else { + XCTFail("Couldn't find comment with `id1`") + return + } + guard let comment2 = comments.first(where: { $0.id == "id2" }) else { + XCTFail("Couldn't find comment with `id2`") + return + } + guard let post1 = comment1.post else { + XCTFail("missing post on comment1") + return + } + guard let post2 = comment2.post else { + XCTFail("missing post on comment2") + return + } + + switch post1.modelProvider.getState() { + case .notLoaded(let identifier): + XCTAssertEqual(identifier, "postId1") + case .loaded: + XCTFail("Should be not loaded") + } + switch post2.modelProvider.getState() { + case .notLoaded(let identifier): + XCTAssertEqual(identifier, "postId2") + case .loaded: + XCTFail("Should be not loaded") + } + } +} + +// MARK: - Models + +public struct LazyParentPost4V2: Model { + public let id: String + public var title: String + public var comments: List? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + title: String, + comments: List? = []) { + self.init(id: id, + title: title, + comments: comments, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + title: String, + comments: List? = [], + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.title = title + self.comments = comments + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} +extension LazyParentPost4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case title + case comments + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let post4V2 = Post4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Post4V2s" + + model.fields( + .id(), + .field(post4V2.title, is: .required, ofType: .string), + .hasMany(post4V2.comments, is: .optional, ofType: LazyChildComment4V2.self, associatedWith: LazyChildComment4V2.keys.post), + .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +public struct LazyChildComment4V2: Model { + public let id: String + public var content: String + public var post: LazyModel? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + content: String, + post: LazyParentPost4V2? = nil) { + self.init(id: id, + content: content, + post: post, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + content: String, + post: LazyParentPost4V2? = nil, + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.content = content + self.post = LazyModel(element: post) + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +extension LazyChildComment4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case content + case post + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let comment4V2 = Comment4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Comment4V2s" + + model.attributes( + .index(fields: ["postID", "content"], name: "byPost4") + ) + + model.fields( + .id(), + .field(comment4V2.content, is: .required, ofType: .string), + .belongsTo(comment4V2.post, is: .optional, ofType: LazyParentPost4V2.self, targetName: "postID"), + .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsPostComment4V2Tests.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsPostComment4V2Tests.swift new file mode 100644 index 0000000000..f971aed83e --- /dev/null +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsPostComment4V2Tests.swift @@ -0,0 +1,328 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite +import XCTest + +@testable import Amplify +@testable import AmplifyTestCommon +@testable import AWSDataStorePlugin + +final class StorageEngineTestsPostComment4V2Tests: StorageEngineTestsBase { + + override func setUp() { + super.setUp() + Amplify.Logging.logLevel = .warn + + let validAPIPluginKey = "MockAPICategoryPlugin" + let validAuthPluginKey = "MockAuthCategoryPlugin" + do { + connection = try Connection(.inMemory) + storageAdapter = try SQLiteStorageEngineAdapter(connection: connection) + try storageAdapter.setUp(modelSchemas: StorageEngine.systemModelSchemas) + + syncEngine = MockRemoteSyncEngine() + storageEngine = StorageEngine(storageAdapter: storageAdapter, + dataStoreConfiguration: .default, + syncEngine: syncEngine, + validAPIPluginKey: validAPIPluginKey, + validAuthPluginKey: validAuthPluginKey) + + ModelListDecoderRegistry.registerDecoder(DataStoreListDecoder.self) + ModelProviderRegistry.registerDecoder(DataStoreModelDecoder.self) + + ModelRegistry.register(modelType: ParentPost4V2.self) + ModelRegistry.register(modelType: ChildComment4V2.self) + do { + try storageEngine.setUp(modelSchemas: [ParentPost4V2.schema]) + try storageEngine.setUp(modelSchemas: [ChildComment4V2.schema]) + } catch { + XCTFail("Failed to setup storage engine") + } + } catch { + XCTFail(String(describing: error)) + return + } + } + + func testQueryComment() async throws { + let comment = ChildComment4V2(content: "content") + _ = try await saveAsync(comment) + + guard (try await queryAsync(ChildComment4V2.self, + byIdentifier: comment.id)) != nil else { + XCTFail("Failed to query saved comment") + return + } + } + + func testQueryPost() async throws { + let post = ParentPost4V2(title: "title") + _ = try await saveAsync(post) + + guard (try await queryAsync(ParentPost4V2.self, + byIdentifier: post.id)) != nil else { + XCTFail("Failed to query saved post") + return + } + } + + func testQueryListComments() async throws { + _ = try await saveAsync(ChildComment4V2(content: "content")) + _ = try await saveAsync(ChildComment4V2(content: "content")) + + let comments = try await queryAsync(ChildComment4V2.self) + XCTAssertEqual(comments.count, 2) + } + + func testQueryListPosts() async throws { + _ = try await saveAsync(ParentPost4V2(title: "title")) + _ = try await saveAsync(ParentPost4V2(title: "title")) + + let comments = try await queryAsync(ParentPost4V2.self) + XCTAssertEqual(comments.count, 2) + } + + func testPostHasLazyLoadedComments() async throws { + let post = ParentPost4V2(title: "title") + _ = try await saveAsync(post) + let comment = ChildComment4V2(content: "content", post: post) + _ = try await saveAsync(comment) + + guard let queriedPost = try await queryAsync(ParentPost4V2.self, + byIdentifier: post.id) else { + XCTFail("Failed to query saved post") + return + } + XCTAssertEqual(queriedPost.id, post.id) + guard let comments = queriedPost.comments else { + XCTFail("Failed to get comments from queried post") + return + } + + switch comments.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, post.id) + XCTAssertEqual(associatedField, "post") + case .loaded(let comments): + print("loaded comments \(comments)") + XCTFail("Should not be loaded") + } + } + + func testCommentHasEagerLoadedPost() async throws { + let post = ParentPost4V2(title: "title") + _ = try await saveAsync(post) + let comment = ChildComment4V2(content: "content", post: post) + _ = try await saveAsync(comment) + + guard let queriedComment = try await queryAsync(ChildComment4V2.self, + byIdentifier: comment.id) else { + XCTFail("Failed to query saved comment") + return + } + XCTAssertEqual(queriedComment.id, comment.id) + guard let eagerLoadedPost = queriedComment.post else { + XCTFail("post should be eager loaded") + return + } + XCTAssertEqual(eagerLoadedPost.id, post.id) + } + + func testCommentHasEagerLoadedPost_InsertUpdateSelect() async throws { + let post = ParentPost4V2(title: "title") + _ = try await saveAsync(post) + var comment = ChildComment4V2(content: "content", post: post) + + // Insert + let insertStatement = InsertStatement(model: comment, modelSchema: ChildComment4V2.schema) + print(insertStatement.stringValue) + print(insertStatement.variables) + _ = try connection.prepare(insertStatement.stringValue).run(insertStatement.variables) + + // Update + comment.content = "updatedContent" + let updateStatement = UpdateStatement(model: comment, + modelSchema: ChildComment4V2.schema, + condition: nil) + _ = try connection.prepare(updateStatement.stringValue).run(updateStatement.variables) + + + // Select + let selectStatement = SelectStatement(from: ChildComment4V2.schema, + predicate: field("id").eq(comment.id), + sort: nil, + paginationInput: nil, + eagerLoad: true) + let rows = try connection.prepare(selectStatement.stringValue).run(selectStatement.variables) + print(rows) + let result: [ModelValues] = try rows.convertToModelValues(to: ChildComment4V2.self, + withSchema: ChildComment4V2.schema, + using: selectStatement) + print(result) + XCTAssertEqual(result.count, 1) + // asert content is "updatedContent" + } + + func testComentHasEagerLoadedPost() async throws { + let post1 = try await saveAsync(ParentPost4V2(id: "postId1", title: "title1")) + _ = try await saveAsync(ChildComment4V2(id: "id1", content: "content", post: post1)) + let post2 = try await saveAsync(ParentPost4V2(id: "postId2", title: "title2")) + _ = try await saveAsync(ChildComment4V2(id: "id2", content: "content", post: post2)) + let comments = try await queryAsync(ChildComment4V2.self) + XCTAssertEqual(comments.count, 2) + guard let comment1 = comments.first(where: { $0.id == "id1" }) else { + XCTFail("Couldn't find comment with `id1`") + return + } + guard let comment2 = comments.first(where: { $0.id == "id2" }) else { + XCTFail("Couldn't find comment with `id2`") + return + } + guard let post1 = comment1.post else { + XCTFail("missing post on comment1") + return + } + XCTAssertEqual(post1.id, "postId1") + XCTAssertEqual(post1.title, "title1") + guard let post2 = comment2.post else { + XCTFail("missing post on comment2") + return + } + XCTAssertEqual(post2.id, "postId2") + XCTAssertEqual(post2.title, "title2") + } + +} + +// MARK: - Models + +public struct ParentPost4V2: Model { + public let id: String + public var title: String + public var comments: List? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + title: String, + comments: List? = []) { + self.init(id: id, + title: title, + comments: comments, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + title: String, + comments: List? = [], + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.title = title + self.comments = comments + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} +extension ParentPost4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case title + case comments + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let post4V2 = Post4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Post4V2s" + + model.fields( + .id(), + .field(post4V2.title, is: .required, ofType: .string), + .hasMany(post4V2.comments, is: .optional, ofType: ChildComment4V2.self, associatedWith: ChildComment4V2.keys.post), + .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +public struct ChildComment4V2: Model { + public let id: String + public var content: String + public var post: ParentPost4V2? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + content: String, + post: ParentPost4V2? = nil) { + self.init(id: id, + content: content, + post: post, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + content: String, + post: ParentPost4V2? = nil, + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.content = content + self.post = post + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +extension ChildComment4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case content + case post + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let comment4V2 = Comment4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Comment4V2s" + + model.attributes( + .index(fields: ["postID", "content"], name: "byPost4") + ) + + model.fields( + .id(), + .field(comment4V2.content, is: .required, ofType: .string), + .belongsTo(comment4V2.post, is: .optional, ofType: ParentPost4V2.self, targetName: "postID"), + .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/Support/MockSQLiteStorageEngineAdapter.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/Support/MockSQLiteStorageEngineAdapter.swift index 7360724cf7..2e3d4b438a 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/Support/MockSQLiteStorageEngineAdapter.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/Support/MockSQLiteStorageEngineAdapter.swift @@ -128,6 +128,7 @@ class MockSQLiteStorageEngineAdapter: StorageEngineAdapter { func query(_ modelType: M.Type, predicate: QueryPredicate?, paginationInput: QueryPaginationInput?, + eagerLoad: Bool, completion: DataStoreCallback<[M]>) { XCTFail("Not expected to execute") } @@ -137,6 +138,7 @@ class MockSQLiteStorageEngineAdapter: StorageEngineAdapter { predicate: QueryPredicate?, sort: [QuerySortDescriptor]?, paginationInput: QueryPaginationInput?, + eagerLoad: Bool, completion: (DataStoreResult<[M]>) -> Void) { XCTFail("Not expected to execute") } @@ -196,6 +198,7 @@ class MockSQLiteStorageEngineAdapter: StorageEngineAdapter { predicate: QueryPredicate?, sort: [QuerySortDescriptor]?, paginationInput: QueryPaginationInput?, + eagerLoad: Bool, completion: DataStoreCallback<[M]>) { if let responder = responders[.queryModelTypePredicate] as? QueryModelTypePredicateResponder { @@ -356,6 +359,7 @@ class MockStorageEngineBehavior: StorageEngineBehavior { predicate: QueryPredicate?, sort: [QuerySortDescriptor]?, paginationInput: QueryPaginationInput?, + eagerLoad: Bool, completion: DataStoreCallback<[M]>) { if let responder = responders[.query] as? QueryResponder { let result = responder.callback(()) @@ -370,6 +374,7 @@ class MockStorageEngineBehavior: StorageEngineBehavior { predicate: QueryPredicate?, sort: [QuerySortDescriptor]?, paginationInput: QueryPaginationInput?, + eagerLoad: Bool, completion: (DataStoreResult<[M]>) -> Void) { if let responder = responders[.query] as? QueryResponder { From 69b5b0b29cc7a0658464f3e5eca19f773c37e537 Mon Sep 17 00:00:00 2001 From: Michael Law <1365977+lawmicha@users.noreply.github.com> Date: Thu, 13 Oct 2022 23:24:49 -0400 Subject: [PATCH 3/8] add SQL and GraphQL tests for PostComment4V2 --- .../Model/Internal/ModelProvider.swift | 2 +- .../Core/AppSyncListDecoder.swift | 4 +- .../Core/AppSyncListProvider.swift | 26 +- .../Core/AppSyncModelMetadata.swift | 71 +- .../GraphQLResponseDecoder+DecodeData.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- ...sponseDecoderLazyPostComment4V2Tests.swift | 873 +++++++----------- ...QLResponseDecoderPostComment4V2Tests.swift | 689 +++++++------- .../Model/Support/Model+GraphQL.swift | 11 +- .../GraphQLRequestModelTests.swift | 2 +- ...ataStorePlugin+DataStoreBaseBehavior.swift | 6 +- .../Core/DataStoreModelDecoder.swift | 4 + .../Core/DataStoreModelProvider.swift | 18 +- .../Storage/ModelStorageBehavior.swift | 2 + .../Storage/SQLite/Model+SQLite.swift | 11 +- .../Storage/SQLite/Statement+Model.swift | 22 +- .../SQLite/StorageEngineAdapter+SQLite.swift | 21 +- .../Storage/StorageEngine.swift | 13 +- .../InitialSync/InitialSyncOperation.swift | 2 +- ...atabaseAdapter+MutationEventIngester.swift | 2 +- ...nDatabaseAdapter+MutationEventSource.swift | 2 +- .../MutationEventClearState.swift | 2 +- ...ocessMutationErrorFromCloudOperation.swift | 2 +- .../ReconcileAndLocalSaveOperation.swift | 2 +- .../Support/MutationEvent+Extensions.swift | 2 +- .../Storage/StorageEngineTestsBase.swift | 5 +- ...geEngineTestsLazyPostComment4V2Tests.swift | 532 ++++------- ...torageEngineTestsPostComment4V2Tests.swift | 329 +++---- .../MockSQLiteStorageEngineAdapter.swift | 8 +- .../SharedTestCasesPostComment4V2.swift | 320 +++++++ 30 files changed, 1478 insertions(+), 1511 deletions(-) create mode 100644 AmplifyTestCommon/SharedTestCases/SharedTestCasesPostComment4V2.swift diff --git a/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift b/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift index 829a0c94fb..c054da45dd 100644 --- a/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift +++ b/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift @@ -41,6 +41,6 @@ public protocol ModelProvider { } public enum ModelProviderState { - case notLoaded(identifier: String) + case notLoaded(identifiers: [String: String]) case loaded(Element?) } diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift index 1b281b572b..2c2666767a 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift @@ -56,7 +56,7 @@ public struct AppSyncListDecoder: ModelListDecoder { public struct AppSyncModelDecoder: ModelProviderDecoder { public static func shouldDecode(modelType: ModelType.Type, decoder: Decoder) -> Bool { - if (try? AppSyncPartialModelMetadata(from: decoder)) != nil { + if (try? AppSyncModelIdentifierMetadata(from: decoder)) != nil { return true } @@ -80,7 +80,7 @@ public struct AppSyncModelDecoder: ModelProviderDecoder { decoder: Decoder) throws -> AppSyncModelProvider? { if let model = try? ModelType.init(from: decoder) { return AppSyncModelProvider(model: model) - } else if let metadata = try? AppSyncPartialModelMetadata.init(from: decoder) { + } else if let metadata = try? AppSyncModelIdentifierMetadata.init(from: decoder) { return AppSyncModelProvider(metadata: metadata) } let json = try JSONValue(from: decoder) diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift index 99287df2b0..2c53b4daf1 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift @@ -14,15 +14,15 @@ public class AppSyncModelProvider: ModelProvider { let apiName: String? enum LoadedState { - case notLoaded(identifier: String) + case notLoaded(identifiers: [String: String]) case loaded(model: ModelType?) } var loadedState: LoadedState // init(AppSyncModelMetadata) creates a notLoaded provider - convenience init(metadata: AppSyncPartialModelMetadata) { - self.init(identifier: metadata.identifier, + convenience init(metadata: AppSyncModelIdentifierMetadata) { + self.init(identifiers: metadata.identifiers, apiName: metadata.apiName) } @@ -33,8 +33,8 @@ public class AppSyncModelProvider: ModelProvider { } // Initializer for not loaded state - init(identifier: String, apiName: String? = nil) { - self.loadedState = .notLoaded(identifier: identifier) + init(identifiers: [String: String], apiName: String? = nil) { + self.loadedState = .notLoaded(identifiers: identifiers) self.apiName = apiName } @@ -44,11 +44,15 @@ public class AppSyncModelProvider: ModelProvider { public func load() async throws -> ModelType? { switch loadedState { - case .notLoaded(let identifier): + case .notLoaded(let identifiers): + // TODO: account for more than one identifier + guard let identifier = identifiers.first else { + throw CoreError.operation("CPK not yet implemented", "", nil) + } let request = GraphQLRequest.getQuery(responseType: ModelType.self, - modelSchema: ModelType.schema, - identifier: identifier, - apiName: apiName) + modelSchema: ModelType.schema, + identifier: identifier.value, + apiName: apiName) do { let graphQLResponse = try await Amplify.API.query(request: request) switch graphQLResponse { @@ -76,8 +80,8 @@ public class AppSyncModelProvider: ModelProvider { public func getState() -> ModelProviderState { switch loadedState { - case .notLoaded(let identifier): - return .notLoaded(identifier: identifier) + case .notLoaded(let identifiers): + return .notLoaded(identifiers: identifiers) case .loaded(let model): return .loaded(model) } diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift index 9928ccd14d..5f8e6269e0 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift @@ -16,8 +16,9 @@ public struct AppSyncModelMetadata: Codable { } /// Metadata that contains partial information of a model -public struct AppSyncPartialModelMetadata: Codable { - let identifier: String +// TODO this should expand to more than just the identifier for composite keys. +public struct AppSyncModelIdentifierMetadata: Codable { + let identifiers: [String: String] let apiName: String? } @@ -28,7 +29,7 @@ public struct AppSyncModelMetadataUtils { // It needs to have the Model type from `__typename` so it can populate it as "associatedField" // // This check is currently broken for CPK use cases since the identifier may not be named `id` anymore - // and also can be a composite key made up of multiple fields. + // and can be a composite key made up of multiple fields. static func shouldAddMetadata(toModel graphQLData: JSONValue) -> Bool { guard case let .object(modelJSON) = graphQLData, case let .string(modelName) = modelJSON["__typename"], @@ -81,17 +82,29 @@ public struct AppSyncModelMetadataUtils { encoder.dateEncodingStrategy = ModelDateFormatting.encodingStrategy let decoder = JSONDecoder() decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy - // Iterate over the associations of the model and for each association, store its association data when - // the object at the association is empty. For example, if the modelType is a Post and has a field that is an - // array association like Comment, store the post's identifier and the ModelField name of the parent, ie. - // "post" in the comments object as metadata. + + // Iterate over the associations of the model and for each association, either create the identifier metadata + // for lazy loading belongs-to or create the model association metadata for lazy loading has-many. + // The metadata gets decoded to the LazyModel and List implementations respectively. for modelField in modelSchema.fields.values { + // Handle Belongs-to associations. For the current `modelField` that is a belongs-to association, + // retrieve the data and attempt to decode to the association's modelType. If it can be decoded, + // this means it is eager loaded and does not need to be lazy loaded. If it cannot, extract the + // identifiers out of the data in the AppSyncModelIdentifierMetadata and store that in place for + // the LazyModel to decode from. if !modelField.isArray && modelField.hasAssociation, let nestedModelJSON = modelJSON[modelField.name], - let partialModelMetadata = isPartialModel(nestedModelJSON, apiName: apiName) { - - if let serializedMetadata = try? encoder.encode(partialModelMetadata), + case .object(let modelObject) = nestedModelJSON, + let associatedModelName = modelField.associatedModelName, + let associatedModelType = ModelRegistry.modelType(from: associatedModelName), + let serializedModelObject = try? encoder.encode(modelObject), + !((try? decoder.decode(associatedModelType.self, from: serializedModelObject)) != nil), + let modelIdentifierMetadata = containsOnlyIdentifiers(associatedModelType, + modelObject: modelObject, + apiName: apiName) { + + if let serializedMetadata = try? encoder.encode(modelIdentifierMetadata), let metadataJSON = try? decoder.decode(JSONValue.self, from: serializedMetadata) { modelJSON.updateValue(metadataJSON, forKey: modelField.name) } else { @@ -101,6 +114,11 @@ public struct AppSyncModelMetadataUtils { } } + // Handle Has-many. Store the association data (parent's identifier and field name) only when the model + // at the association is empty. If it's not empty, that means the has-many has been eager loaded. + // For example, when traversing the Post's fields and encounters the has-many association Comment, store + // the association metadata containing the post's identifier at comment, to be decoded to the List + // This allows the list to perform lazy loading of the Comments with a filter on the post's identifier. if modelField.isArray && modelField.hasAssociation, let associatedField = modelField.associatedField, modelJSON[modelField.name] == nil { @@ -121,24 +139,25 @@ public struct AppSyncModelMetadataUtils { return JSONValue.object(modelJSON) } - // A partial model is when only the values of the identifier of the model exists, and nothing else. - // Traverse of the primary keys of the model, and check if there's exactly those values exists. - // This means that the model are missing required/optional fields that are not the identifier of the model. - // TODO: This code needs to account for CPK. - static func isPartialModel(_ modelJSON: JSONValue, apiName: String?) -> AppSyncPartialModelMetadata? { - guard case .object(let modelObject) = modelJSON else { - return nil - } + // At this point, we know the model cannot be decoded to the fully model + // so extract the primary key and values out. + static func containsOnlyIdentifiers(_ associatedModel: Model.Type, + modelObject: [String: JSONValue], + apiName: String?) -> AppSyncModelIdentifierMetadata? { + let primarykeys = associatedModel.schema.primaryKey + print("primaryKeys \(primarykeys)") - // TODO: This should be based on the number of fields that make up the identifier + __typename - guard modelObject.count == 2 else { - return nil + var identifiers = [String: String]() + for identifierField in primarykeys.fields { + if case .string(let id) = modelObject[identifierField.name] { + print("Found key value \(identifierField.name) value: \(id)") + identifiers.updateValue(id, forKey: identifierField.name) + } } - - if case .string(let id) = modelObject["id"] { - return AppSyncPartialModelMetadata(identifier: id, apiName: apiName) + if !identifiers.isEmpty { + return AppSyncModelIdentifierMetadata(identifiers: identifiers, apiName: apiName) + } else { + return nil } - - return nil } } diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLResponseDecoder+DecodeData.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLResponseDecoder+DecodeData.swift index be59731a6f..2d62ad41c6 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLResponseDecoder+DecodeData.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLResponseDecoder+DecodeData.swift @@ -40,7 +40,7 @@ extension GraphQLResponseDecoder { if request.responseType == AnyModel.self { // 2 let anyModel = try AnyModel(modelJSON: graphQLData) serializedJSON = try encoder.encode(anyModel) - } else if request.responseType is ModelListMarker.Type, // 2 + } else if request.responseType is ModelListMarker.Type, // 3 case .object(var graphQLDataObject) = graphQLData, case .array(var graphQLDataArray) = graphQLDataObject["items"] { for (index, item) in graphQLDataArray.enumerated() { diff --git a/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3ca522a287..f797f00186 100644 --- a/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/awslabs/aws-sdk-swift.git", "state" : { - "revision" : "eea9a9ac6aab16e99eec10169a56336f79ce2e37", - "version" : "0.2.6" + "revision" : "76d1b43bfc3eeafd9d09e5aa307e629882192a7d", + "version" : "0.2.7" } }, { diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift index a2f0261ad4..b69ac3fe8c 100644 --- a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderLazyPostComment4V2Tests.swift @@ -12,13 +12,14 @@ import AWSPluginsCore @testable import AWSAPIPlugin // Decoder tests for ParentPost4V2 and ChildComment4V2 -class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { +class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase, SharedTestCasesPostComment4V2 { let decoder = JSONDecoder() let encoder = JSONEncoder() override func setUp() async throws { await Amplify.reset() + Amplify.Logging.logLevel = .verbose ModelRegistry.register(modelType: LazyParentPost4V2.self) ModelRegistry.register(modelType: LazyChildComment4V2.self) ModelListDecoderRegistry.registerDecoder(AppSyncListDecoder.self) @@ -28,122 +29,120 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { encoder.dateEncodingStrategy = ModelDateFormatting.encodingStrategy } - /* - This test will start to fail if we remove the optionality on the LazyModel type, from - ``` - public struct LazyChildComment4V2: Model { - public var post: LazyModel? - ``` - to - ``` - public struct LazyChildComment4V2: Model { - public var post: LazyModel - ``` - with the error - ``` - "keyNotFound(CodingKeys(stringValue: "post", intValue: nil), - Swift.DecodingError.Context(codingPath: [], - debugDescription: "No value associated with key CodingKeys(stringValue: \"post\", intValue: nil) - (\"post\").", underlyingError: nil))" - ``` - */ - func testQueryComment() throws { - let request = GraphQLRequest(document: "", - responseType: LazyChildComment4V2.self, - decodePath: "getLazyChildComment4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + func testSaveCommentThenQueryComment() async throws { + let comment = LazyChildComment4V2(content: "content") + // Create request + let request = GraphQLRequest.create(comment) + var documentString = """ + mutation CreateLazyChildComment4V2($input: CreateLazyChildComment4V2Input!) { + createLazyChildComment4V2(input: $input) { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + } + """ + XCTAssertEqual(request.document, documentString) + guard let variables = request.variables, + let input = variables["input"] as? [String: Any] else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(input["id"] as? String, comment.id) + XCTAssertEqual(input["content"] as? String, comment.content) - let graphQLData: [String: JSONValue] = [ - "getLazyChildComment4V2": [ - "id": "id", - "content": "content" - ] - ] - - let result = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(result.id, "id") - XCTAssertEqual(result.content, "content") - } - - func testQueryPost() throws { - let request = GraphQLRequest(document: "", - responseType: LazyParentPost4V2.self, - decodePath: "getLazyParentPost4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + // Get request + let getRequest = GraphQLRequest.get(LazyChildComment4V2.self, byId: comment.id) + documentString = """ + query GetLazyChildComment4V2($id: ID!) { + getLazyChildComment4V2(id: $id) { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + } + """ + XCTAssertEqual(getRequest.document, documentString) + guard let variables = getRequest.variables, + let id = variables["id"] as? String else { + XCTFail("Missing request.variables id") + return + } + XCTAssertEqual(id, comment.id) + // Decode data + let decoder = GraphQLResponseDecoder(request: getRequest.toOperationRequest(operationType: .mutation)) let graphQLData: [String: JSONValue] = [ - "getLazyParentPost4V2": [ + "\(getRequest.decodePath!)": [ "id": "id", - "title": "title" - ] - ] - - let result = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(result.id, "id") - XCTAssertEqual(result.title, "title") - } - - func testQueryListComments() throws { - let request = GraphQLRequest>(document: "", - responseType: List.self, - decodePath: "listLazyChildComment4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) - - let graphQLData: [String: JSONValue] = [ - "listLazyChildComment4V2": [ - "items": [ - [ - "id": "id1", - "content": "content1" - ], - [ - "id": "id2", - "content": "content2" - ] - ] + "content": "content", + "createdAt": nil, + "updatedAt": nil, + "post": nil, + "__typename": "LazyChildComment4V2" ] ] - let result = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(result.count, 2) - let comment1 = result.first { $0.id == "id1" } - let comment2 = result.first { $0.id == "id2" } - XCTAssertNotNil(comment1) - XCTAssertNotNil(comment2) + guard let savedComment = try decoder.decodeToResponseType(graphQLData) else { + XCTFail("Could not decode to comment") + return + } + XCTAssertEqual(savedComment.id, "id") + XCTAssertEqual(savedComment.content, "content") + switch savedComment._post.modelProvider.getState() { + case .notLoaded: + XCTFail("should be loaded, with `nil` element") + case .loaded(let element): + XCTAssertNil(element) + } } - func testQueryListPosts() throws { - let request = GraphQLRequest>(document: "", - responseType: List.self, - decodePath: "listLazyParentPost4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + func testSavePostThenQueryPost() async throws { + let post = LazyParentPost4V2(title: "title") - let graphQLData: [String: JSONValue] = [ - "listLazyParentPost4V2": [ - "items": [ - [ - "id": "id1", - "title": "title" - ], - [ - "id": "id2", - "title": "title" - ] - ] - ] - ] - - let result = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(result.count, 2) - let post1 = result.first { $0.id == "id1" } - let post2 = result.first { $0.id == "id2" } - XCTAssertNotNil(post1) - XCTAssertNotNil(post2) - } + // Create request + let request = GraphQLRequest.create(post) + var documentString = """ + mutation CreateLazyParentPost4V2($input: CreateLazyParentPost4V2Input!) { + createLazyParentPost4V2(input: $input) { + id + createdAt + title + updatedAt + __typename + } + } + """ + XCTAssertEqual(request.document, documentString) + guard let variables = request.variables, + let input = variables["input"] as? [String: Any] else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(input["id"] as? String, post.id) + XCTAssertEqual(input["title"] as? String, post.title) - func testPostHasLazyLoadedComments() throws { - let request = GraphQLRequest.get(LazyParentPost4V2.self, byId: "id") - let documentStringValue = """ + // Get request + let getRequest = GraphQLRequest.get(LazyParentPost4V2.self, byId: post.id) + documentString = """ query GetLazyParentPost4V2($id: ID!) { getLazyParentPost4V2(id: $id) { id @@ -154,532 +153,372 @@ class GraphQLResponseDecoderLazyPostComment4V2Tests: XCTestCase { } } """ - XCTAssertEqual(request.document, documentStringValue) - XCTAssertEqual(request.decodePath, "getLazyParentPost4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + XCTAssertEqual(getRequest.document, documentString) + guard let variables = getRequest.variables, + let id = variables["id"] as? String else { + XCTFail("Missing request.variables id") + return + } + XCTAssertEqual(id, post.id) + // Decode data + let decoder = GraphQLResponseDecoder(request: getRequest.toOperationRequest(operationType: .mutation)) let graphQLData: [String: JSONValue] = [ - "\(request.decodePath!)": [ + "\(getRequest.decodePath!)": [ "id": "postId", "title": "title", + "createdAt": nil, + "updatedAt": nil, "__typename": "LazyParentPost4V2" ] ] - let result = try decoder.decodeToResponseType(graphQLData) - guard let post = result else { + guard let decodedPost = try decoder.decodeToResponseType(graphQLData) else { XCTFail("Failed to decode to post") return } - XCTAssertEqual(post.id, "postId") - XCTAssertEqual(post.title, "title") - guard let comments = post.comments else { - XCTFail("Could not create list of comments") + XCTAssertEqual(decodedPost.id, "postId") + XCTAssertEqual(decodedPost.title, "title") + guard let comments = decodedPost.comments else { + XCTFail("Failed to create lazy list of comments") return } - let state = comments.listProvider.getState() - switch state { + switch comments.listProvider.getState() { case .notLoaded(let associatedId, let associatedField): XCTAssertEqual(associatedId, "postId") - XCTAssertEqual(associatedField, "post") + XCTAssertEqual(associatedField, LazyChildComment4V2.CodingKeys.post.stringValue) case .loaded: - XCTFail("Should be not loaded") + XCTFail("Should be not loaded with post data") } } - func testPostHasEagerLoadedComments() throws { - // Since we are mocking `graphQLData` below, it does not matter what selection set is contained - // inside the `document` parameter, however for an integration level test, the custom selection set - // should contain two levels, the post fields and the nested comment fields. - let request = GraphQLRequest(document: "", - responseType: LazyParentPost4V2?.self, - decodePath: "getLazyParentPost4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + func testSaveMultipleThenQueryComments() async throws { + let request = GraphQLRequest.list(LazyChildComment4V2.self) + let documentString = """ + query ListLazyChildComment4V2s($limit: Int) { + listLazyChildComment4V2s(limit: $limit) { + items { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + nextToken + } + } + """ + XCTAssertEqual(request.document, documentString) + guard let variables = request.variables, + let limit = variables["limit"] as? Int else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(limit, 1000) + let decoder = GraphQLResponseDecoder>( + request: request.toOperationRequest(operationType: .query)) let graphQLData: [String: JSONValue] = [ - "getLazyParentPost4V2": [ - "id": "postId", - "title": "title", - "__typename": "LazyParentPost4V2", - "comments": [ + "\(request.decodePath!)": [ + "items": [ [ "id": "id1", - "content": "content1" + "content": "content1", + "__typename": "LazyChildComment4V2", + "post": nil ], [ "id": "id2", - "content": "content2" - ] - ] + "content": "content2", + "__typename": "LazyChildComment4V2", + "post": nil + ], + ], + "nextToken": "nextToken" ] ] - let result = try decoder.decodeToResponseType(graphQLData) - guard let post = result else { - XCTFail("Failed to decode to post") - return - } - XCTAssertEqual(post.id, "postId") - XCTAssertEqual(post.title, "title") - guard let comments = post.comments else { - XCTFail("Could not create list of comments") - return - } - let state = comments.listProvider.getState() - switch state { + let queriedList = try decoder.decodeToResponseType(graphQLData) + switch queriedList.listProvider.getState() { case .notLoaded: - XCTFail("Should be loaded") - case .loaded(let comments): - XCTAssertEqual(comments.count, 2) - let comment1 = comments.first { $0.id == "id1" } - let comment2 = comments.first { $0.id == "id2" } - XCTAssertNotNil(comment1) - XCTAssertNotNil(comment2) + XCTFail("A direct query should have a loaded list") + case .loaded: + break } + XCTAssertEqual(queriedList.count, 2) + let comment1 = queriedList.first { $0.id == "id1" } + let comment2 = queriedList.first { $0.id == "id2" } + XCTAssertNotNil(comment1) + XCTAssertNotNil(comment2) + XCTAssertTrue(queriedList.hasNextPage()) } - func testCommentHasEagerLoadedPost() throws { - // By default, the `.get` for a child model with belongs-to parent creates a nested selection set - // as shown below by `documentStringValue`, so we mock the `graphQLData` with a nested object - // comment containing a post - let request = GraphQLRequest.get(LazyChildComment4V2.self, byId: "id") - let documentStringValue = """ - query GetLazyChildComment4V2($id: ID!) { - getLazyChildComment4V2(id: $id) { - id - content - createdAt - updatedAt - post { + func testSaveMultipleThenQueryPosts() async throws { + let request = GraphQLRequest.list(LazyParentPost4V2.self) + let documentString = """ + query ListLazyParentPost4V2s($limit: Int) { + listLazyParentPost4V2s(limit: $limit) { + items { id createdAt title updatedAt __typename } - __typename + nextToken } } """ - XCTAssertEqual(request.document, documentStringValue) - XCTAssertEqual(request.decodePath, "getLazyChildComment4V2") + XCTAssertEqual(request.document, documentString) + guard let variables = request.variables, + let limit = variables["limit"] as? Int else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(limit, 1000) let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) let graphQLData: [String: JSONValue] = [ - "getLazyChildComment4V2": [ + "\(request.decodePath!)": [ + "items": [ + [ + "id": "id1", + "title": "title", + "__typename": "LazyParentPost4V2", + ], + [ + "id": "id2", + "title": "title", + "__typename": "LazyParentPost4V2", + ] + ], + "nextToken": "nextToken" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.count, 2) + let post1 = result.first { $0.id == "id1" } + let post2 = result.first { $0.id == "id2" } + XCTAssertNotNil(post1) + XCTAssertNotNil(post2) + } + + func testSaveCommentWithPostThenQueryCommentAndAccessPost() async throws { + let post = LazyParentPost4V2(title: "title") + let comment = LazyChildComment4V2(content: "content", post: post) + + let request = GraphQLRequest.create(comment) + guard let variables = request.variables, + let input = variables["input"] as? [String: Any] else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(input["id"] as? String, comment.id) + XCTAssertEqual(input["content"] as? String, comment.content) + XCTAssertEqual(input["postID"] as? String, post.id) + + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + var graphQLData: [String: JSONValue] = [ + "\(request.decodePath!)": [ "id": "id", "content": "content", + "createdAt": nil, + "updatedAt": nil, "post": [ - "id": "postId", + "id": .string("\(post.id)"), "title": "title", + "updatedAt": nil, + "createdAt": nil, "__typename": "LazyParentPost4V2" ], "__typename": "LazyChildComment4V2" ] ] - let comment = try decoder.decodeToResponseType(graphQLData) - guard let comment = comment else { - XCTFail("Could not load comment") - return - } - - XCTAssertEqual(comment.id, "id") - XCTAssertEqual(comment.content, "content") - guard let post = comment.post else { - XCTFail("LazModel should be created") - return - } - switch post.modelProvider.getState() { + let commentWithEagerLoadedPost = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(commentWithEagerLoadedPost.id, "id") + XCTAssertEqual(commentWithEagerLoadedPost.content, "content") + switch commentWithEagerLoadedPost._post.modelProvider.getState() { case .notLoaded: - XCTFail("Should have been loaded") - case .loaded(let post): - guard let post = post else { - XCTFail("Loaded with no post") + XCTFail("should be in loaded state when data contains the entire post") + case .loaded(let element): + guard let loadedPost = element else { + XCTFail("loaded state should contain the post") return } - XCTAssertEqual(post.id, "postId") - XCTAssertEqual(post.title, "title") + XCTAssertEqual(loadedPost.id, post.id) } - } - - func testCommentHasLazyLoadPostFromPartialPost() throws { - let request = GraphQLRequest.get(LazyChildComment4V2.self, byId: "id") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) - // The data used to seed the decoder contains the nested `post` that mimics an incomplete selection set - // so it is missing some required fields such as `title` and still be successful in creating a "not loaded" - // lazy model object. - let graphQLData: [String: JSONValue] = [ - "getLazyChildComment4V2": [ + + graphQLData = [ + "\(request.decodePath!)": [ "id": "id", "content": "content", - "post": [ - "id": "postId", + "createdAt": nil, + "updatedAt": nil, + "post": [ // removed most fields except for identifiers + "id": .string("\(post.id)"), "__typename": "LazyParentPost4V2" ], "__typename": "LazyChildComment4V2" ] ] - let comment = try decoder.decodeToResponseType(graphQLData) - guard let comment = comment else { - XCTFail("Could not load comment") - return - } - - XCTAssertEqual(comment.id, "id") - XCTAssertEqual(comment.content, "content") - guard let post = comment.post else { - XCTFail("LazModel should be created") - return - } - switch post.modelProvider.getState() { - case .notLoaded(let id): - XCTAssertEqual(id, "postId") + let commentWithLazyLoadPost = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(commentWithLazyLoadPost.id, "id") + XCTAssertEqual(commentWithLazyLoadPost.content, "content") + switch commentWithLazyLoadPost._post.modelProvider.getState() { + case .notLoaded(let identifiers): + XCTAssertEqual(identifiers["id"], post.id) case .loaded: - XCTFail("Should be not loaded") + XCTFail("should be in not loaded state when post data is partial") } } - func testCommentHasLazyLoadPostFromNilPost() throws { - let request = GraphQLRequest.get(LazyChildComment4V2.self, byId: "id") + func testSaveCommentWithPostThenQueryPostAndAccessComments() async throws { + let post = LazyParentPost4V2(title: "title") + + let request = GraphQLRequest.create(post) let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) - // The data used to seed the decoder contains the nested `post` that mimics an incomplete selection set - // so it is missing some required fields such as `title` and still be successfully in creating a "not loaded" - // lazy model. When the LazyModel decoder runs, it is responsible for attempting to decode to the right state - // either loaded if the entire post object is there, or not loaded when the minimum required "not loaded" - // information is there. let graphQLData: [String: JSONValue] = [ - "getLazyChildComment4V2": [ - "id": "commentId", - "content": "content", - "post": nil, - "__typename": "LazyChildComment4V2" + "\(request.decodePath!)": [ + "id": "postId", + "title": "title", + "createdAt": nil, + "updatedAt": nil, + "__typename": "LazyParentPost4V2" ] ] - let comment = try decoder.decodeToResponseType(graphQLData) - guard let comment = comment else { - XCTFail("Could not load comment") + let decodedPost = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(decodedPost.id, "postId") + XCTAssertEqual(decodedPost.title, "title") + guard let comments = decodedPost.comments else { + XCTFail("Failed to create lazy list of comments") return } - - XCTAssertEqual(comment.id, "commentId") - XCTAssertEqual(comment.content, "content") - guard comment.post == nil else { - XCTFail("lazy model should be nil") - return + switch comments.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, "postId") + XCTAssertEqual(associatedField, LazyChildComment4V2.CodingKeys.post.stringValue) + case .loaded: + XCTFail("Should be not loaded with post data") } } - func testCommentHasLazyLoadPostFromEmptyPost() throws { - let request = GraphQLRequest.get(LazyChildComment4V2.self, byId: "id") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) - let graphQLData: [String: JSONValue] = [ - "getLazyChildComment4V2": [ - "id": "id", - "content": "content", - "__typename": "LazyChildComment4V2" + func testSaveMultipleCommentWithPostThenQueryCommentsAndAccessPost() async throws { + let post = LazyParentPost4V2(title: "title") + let request = GraphQLRequest.list(LazyChildComment4V2.self) + + var graphQLData: [String: JSONValue] = [ + "\(request.decodePath!)": [ + "items": [ + [ + "id": "id1", + "content": "content1", + "__typename": "LazyChildComment4V2", + "post": [ + "id": .string("\(post.id)"), + "__typename": "LazyParentPost4V2" + ], + ], + ], + "nextToken": "nextToken" ] ] - - let comment = try decoder.decodeToResponseType(graphQLData) - guard let comment = comment else { - XCTFail("Could not load comment") - return + let decoder = GraphQLResponseDecoder>( + request: request.toOperationRequest(operationType: .query)) + var queriedList = try decoder.decodeToResponseType(graphQLData) + switch queriedList.listProvider.getState() { + case .notLoaded: + XCTFail("A direct query should have a loaded list") + case .loaded: + break } - - XCTAssertEqual(comment.id, "id") - XCTAssertEqual(comment.content, "content") - guard comment.post == nil else { - XCTFail("LazModel should not be nil") + XCTAssertEqual(queriedList.count, 1) + guard let comment = queriedList.first else { + XCTFail("Failed to decode to comment") return } - } - - func testListCommentHasEagerLoadedPost() throws { - // By default, the `.list` for a list of children models with belongs-to parent creates a nested selection set - // as shown below by `documentStringValue`, so we mock the `graphQLData` with a list of nested objects - // comments, each containing a post - let request = GraphQLRequest>.list(LazyChildComment4V2.self) - let documentStringValue = """ - query ListLazyChildComment4V2s($limit: Int) { - listLazyChildComment4V2s(limit: $limit) { - items { - id - content - createdAt - updatedAt - post { - id - createdAt - title - updatedAt - __typename - } - __typename - } - nextToken - } + switch comment._post.modelProvider.getState() { + case .notLoaded(let identifiers): + XCTAssertEqual(identifiers["id"], post.id) + case .loaded: + XCTFail("Should be in not loaded state") } - """ - XCTAssertEqual(request.document, documentStringValue) - XCTAssertEqual(request.decodePath, "listLazyChildComment4V2s") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) - let graphQLData: [String: JSONValue] = [ - "listLazyChildComment4V2s": [ + graphQLData = [ + "\(request.decodePath!)": [ "items": [ [ "id": "id1", "content": "content1", "__typename": "LazyChildComment4V2", "post": [ - "id": "postId1", - "title": "title1", + "id": .string("\(post.id)"), + "title": "title", + "updatedAt": nil, + "createdAt": nil, "__typename": "LazyParentPost4V2" - ] + ], ], - [ - "id": "id2", - "content": "content2", - "__typename": "LazyChildComment4V2", - "post": [ - "id": "postId2", - "title": "title2", - "__typename": "LazyParentPost4V2" - ] - ] - ] + ], + "nextToken": "nextToken" ] ] - - let comments = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(comments.count, 2) - guard let comment1 = comments.first(where: { $0.id == "id1" }) else { - XCTFail("Couldn't find comment with `id1`") + queriedList = try decoder.decodeToResponseType(graphQLData) + guard let comment = queriedList.first else { + XCTFail("Failed to decode to comment") return } - guard let comment2 = comments.first(where: { $0.id == "id2" }) else { - XCTFail("Couldn't find comment with `id2`") - return - } - guard let post1 = comment1.post else { - XCTFail("missing post on comment1") - return - } - guard let post2 = comment2.post else { - XCTFail("missing post on comment2") - return - } - - switch post1.modelProvider.getState() { - case .notLoaded: - XCTFail("Should be loaded") - case .loaded(let post): - XCTAssertEqual(post!.id, "postId1") - XCTAssertEqual(post!.title, "title1") - } - switch post2.modelProvider.getState() { + switch comment._post.modelProvider.getState() { case .notLoaded: - XCTFail("Should be loaded") - case .loaded(let post): - XCTAssertEqual(post!.id, "postId2") - XCTAssertEqual(post!.title, "title2") + XCTFail("Should be in loaded state") + case .loaded(let element): + guard let loadedPost = element else { + XCTFail("post should be loaded") + return + } + XCTAssertEqual(loadedPost.id, post.id) } } - func testListCommentHasLazyLoadedPartialPost() throws { - // By default, the `.list` for a list of children models with belongs-to parent creates a nested selection set - // as shown below by `documentStringValue`, so we mock the `graphQLData` with a list of nested objects - // comments, each containing a partial post, so that the post will be "not loaded" and requires lazy loading - let request = GraphQLRequest>.list(LazyChildComment4V2.self) - XCTAssertEqual(request.decodePath, "listLazyChildComment4V2s") + func testSaveMultipleCommentWithPostThenQueryPostAndAccessComments() async throws { + let request = GraphQLRequest.list(LazyParentPost4V2.self) let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) - let graphQLData: [String: JSONValue] = [ - "listLazyChildComment4V2s": [ + "\(request.decodePath!)": [ "items": [ [ "id": "id1", - "content": "content1", - "__typename": "LazyChildComment4V2", - "post": [ - "id": "postId1", - "__typename": "LazyParentPost4V2" - ] - ], - [ - "id": "id2", - "content": "content2", - "__typename": "LazyChildComment4V2", - "post": [ - "id": "postId2", - "__typename": "LazyParentPost4V2" - ] + "title": "title", + "__typename": "LazyParentPost4V2", ] - ] + ], + "nextToken": "nextToken" ] ] - - let comments = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(comments.count, 2) - guard let comment1 = comments.first(where: { $0.id == "id1" }) else { - XCTFail("Couldn't find comment with `id1`") - return - } - guard let comment2 = comments.first(where: { $0.id == "id2" }) else { - XCTFail("Couldn't find comment with `id2`") - return - } - guard let post1 = comment1.post else { - XCTFail("missing post on comment1") - return - } - guard let post2 = comment2.post else { - XCTFail("missing post on comment2") + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.count, 1) + guard let post = result.first, + let comments = post.comments else { + XCTFail("Failed to decode to one post, with containing comments") return } - - switch post1.modelProvider.getState() { - case .notLoaded(let identifier): - XCTAssertEqual(identifier, "postId1") - case .loaded: - XCTFail("Should be not loaded") - } - switch post2.modelProvider.getState() { - case .notLoaded(let identifier): - XCTAssertEqual(identifier, "postId2") + switch comments.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, "id1") + XCTAssertEqual(associatedField, "post") case .loaded: - XCTFail("Should be not loaded") + XCTFail("Should be in not loaded state") } } } - - -// MARK: - Models - -public struct LazyParentPost4V2: Model { - public let id: String - public var title: String - public var comments: List? - public var createdAt: Temporal.DateTime? - public var updatedAt: Temporal.DateTime? - - public init(id: String = UUID().uuidString, - title: String, - comments: List? = []) { - self.init(id: id, - title: title, - comments: comments, - createdAt: nil, - updatedAt: nil) - } - internal init(id: String = UUID().uuidString, - title: String, - comments: List? = [], - createdAt: Temporal.DateTime? = nil, - updatedAt: Temporal.DateTime? = nil) { - self.id = id - self.title = title - self.comments = comments - self.createdAt = createdAt - self.updatedAt = updatedAt - } -} -extension LazyParentPost4V2 { - // MARK: - CodingKeys - public enum CodingKeys: String, ModelKey { - case id - case title - case comments - case createdAt - case updatedAt - } - - public static let keys = CodingKeys.self - // MARK: - ModelSchema - - public static let schema = defineSchema { model in - let post4V2 = Post4V2.keys - - model.authRules = [ - rule(allow: .public, operations: [.create, .update, .delete, .read]) - ] - - model.pluralName = "Post4V2s" - - model.fields( - .id(), - .field(post4V2.title, is: .required, ofType: .string), - .hasMany(post4V2.comments, is: .optional, ofType: LazyChildComment4V2.self, associatedWith: LazyChildComment4V2.keys.post), - .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), - .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) - ) - } -} - -public struct LazyChildComment4V2: Model { - public let id: String - public var content: String - public var post: LazyModel? - public var createdAt: Temporal.DateTime? - public var updatedAt: Temporal.DateTime? - - public init(id: String = UUID().uuidString, - content: String, - post: LazyParentPost4V2? = nil) { - self.init(id: id, - content: content, - post: post, - createdAt: nil, - updatedAt: nil) - } - internal init(id: String = UUID().uuidString, - content: String, - post: LazyParentPost4V2? = nil, - createdAt: Temporal.DateTime? = nil, - updatedAt: Temporal.DateTime? = nil) { - self.id = id - self.content = content - self.post = LazyModel(element: post) - self.createdAt = createdAt - self.updatedAt = updatedAt - } -} - -extension LazyChildComment4V2 { - // MARK: - CodingKeys - public enum CodingKeys: String, ModelKey { - case id - case content - case post - case createdAt - case updatedAt - } - - public static let keys = CodingKeys.self - // MARK: - ModelSchema - - public static let schema = defineSchema { model in - let comment4V2 = Comment4V2.keys - - model.authRules = [ - rule(allow: .public, operations: [.create, .update, .delete, .read]) - ] - - model.pluralName = "Comment4V2s" - - model.attributes( - .index(fields: ["postID", "content"], name: "byPost4") - ) - - model.fields( - .id(), - .field(comment4V2.content, is: .required, ofType: .string), - .belongsTo(comment4V2.post, is: .optional, ofType: LazyParentPost4V2.self, targetName: "postID"), - .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), - .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) - ) - } -} diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift index b59bc079ea..de08fed274 100644 --- a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/Support/Decode/GraphQLResponseDecoderPostComment4V2Tests.swift @@ -12,7 +12,7 @@ import AWSPluginsCore @testable import AWSAPIPlugin // Decoder tests for ParentPost4V2 and ChildComment4V2 -class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase { +class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase, SharedTestCasesPostComment4V2 { let decoder = JSONDecoder() let encoder = JSONEncoder() @@ -28,119 +28,115 @@ class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase { encoder.dateEncodingStrategy = ModelDateFormatting.encodingStrategy } - func testDecodeChildCommentResponseTypeForString() throws { - let request = GraphQLRequest(document: "", - responseType: String.self, - decodePath: "getChildComment4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) - - let graphQLData: [String: JSONValue] = [ - "getChildComment4V2": [ - "id": "id" - ] - ] - - let result = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(result, "{\"id\":\"id\"}") - } - - func testQueryComment() throws { - let request = GraphQLRequest(document: "", - responseType: ChildComment4V2.self, - decodePath: "getChildComment4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + func testSaveCommentThenQueryComment() async throws { + let comment = ChildComment4V2(content: "content") + // Create request + let request = GraphQLRequest.create(comment) + var documentString = """ + mutation CreateChildComment4V2($input: CreateChildComment4V2Input!) { + createChildComment4V2(input: $input) { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + } + """ + XCTAssertEqual(request.document, documentString) + guard let variables = request.variables, + let input = variables["input"] as? [String: Any] else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(input["id"] as? String, comment.id) + XCTAssertEqual(input["content"] as? String, comment.content) - let graphQLData: [String: JSONValue] = [ - "getChildComment4V2": [ - "id": "id", - "content": "content" - ] - ] - - let result = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(result.id, "id") - XCTAssertEqual(result.content, "content") - } - - func testQueryPost() throws { - let request = GraphQLRequest(document: "", - responseType: ParentPost4V2.self, - decodePath: "getParentPost4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + // Get request + let getRequest = GraphQLRequest.get(ChildComment4V2.self, byId: comment.id) + documentString = """ + query GetChildComment4V2($id: ID!) { + getChildComment4V2(id: $id) { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + } + """ + XCTAssertEqual(getRequest.document, documentString) + guard let variables = getRequest.variables, + let id = variables["id"] as? String else { + XCTFail("Missing request.variables id") + return + } + XCTAssertEqual(id, comment.id) + // Decode data + let decoder = GraphQLResponseDecoder(request: getRequest.toOperationRequest(operationType: .mutation)) let graphQLData: [String: JSONValue] = [ - "getParentPost4V2": [ + "\(getRequest.decodePath!)": [ "id": "id", - "title": "title" - ] - ] - - let result = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(result.id, "id") - XCTAssertEqual(result.title, "title") - } - - func testQueryListComments() throws { - let request = GraphQLRequest>(document: "", - responseType: List.self, - decodePath: "listChildComment4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) - - let graphQLData: [String: JSONValue] = [ - "listChildComment4V2": [ - "items": [ - [ - "id": "id1", - "content": "content1" - ], - [ - "id": "id2", - "content": "content2" - ] - ] + "content": "content", + "createdAt": nil, + "updatedAt": nil, + "post": nil, + "__typename": "ChildComment4V2" ] ] - let result = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(result.count, 2) - let comment1 = result.first { $0.id == "id1" } - let comment2 = result.first { $0.id == "id2" } - XCTAssertNotNil(comment1) - XCTAssertNotNil(comment2) + guard let savedComment = try decoder.decodeToResponseType(graphQLData) else { + XCTFail("Could not decode to comment") + return + } + XCTAssertEqual(savedComment.id, "id") + XCTAssertEqual(savedComment.content, "content") + XCTAssertNil(savedComment.post) } - func testQueryListPosts() throws { - let request = GraphQLRequest>(document: "", - responseType: List.self, - decodePath: "listParentPost4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + func testSavePostThenQueryPost() async throws { + let post = ParentPost4V2(title: "title") - let graphQLData: [String: JSONValue] = [ - "listParentPost4V2": [ - "items": [ - [ - "id": "id1", - "title": "title" - ], - [ - "id": "id2", - "title": "title" - ] - ] - ] - ] - - let result = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(result.count, 2) - let post1 = result.first { $0.id == "id1" } - let post2 = result.first { $0.id == "id2" } - XCTAssertNotNil(post1) - XCTAssertNotNil(post2) - } + // Create request + let request = GraphQLRequest.create(post) + var documentString = """ + mutation CreateParentPost4V2($input: CreateParentPost4V2Input!) { + createParentPost4V2(input: $input) { + id + createdAt + title + updatedAt + __typename + } + } + """ + XCTAssertEqual(request.document, documentString) + guard let variables = request.variables, + let input = variables["input"] as? [String: Any] else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(input["id"] as? String, post.id) + XCTAssertEqual(input["title"] as? String, post.title) - func testPostHasLazyLoadedComments() throws { - let request = GraphQLRequest.get(ParentPost4V2.self, byId: "id") - let documentStringValue = """ + // Get request + let getRequest = GraphQLRequest.get(ParentPost4V2.self, byId: post.id) + documentString = """ query GetParentPost4V2($id: ID!) { getParentPost4V2(id: $id) { id @@ -151,352 +147,295 @@ class GraphQLResponseDecoderPostComment4V2Tests: XCTestCase { } } """ - XCTAssertEqual(request.document, documentStringValue) - XCTAssertEqual(request.decodePath, "getParentPost4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + XCTAssertEqual(getRequest.document, documentString) + guard let variables = getRequest.variables, + let id = variables["id"] as? String else { + XCTFail("Missing request.variables id") + return + } + XCTAssertEqual(id, post.id) + // Decode data + let decoder = GraphQLResponseDecoder(request: getRequest.toOperationRequest(operationType: .mutation)) let graphQLData: [String: JSONValue] = [ - "\(request.decodePath!)": [ + "\(getRequest.decodePath!)": [ "id": "postId", "title": "title", + "createdAt": nil, + "updatedAt": nil, "__typename": "ParentPost4V2" ] ] - let result = try decoder.decodeToResponseType(graphQLData) - guard let post = result else { + guard let decodedPost = try decoder.decodeToResponseType(graphQLData) else { XCTFail("Failed to decode to post") return } - XCTAssertEqual(post.id, "postId") - XCTAssertEqual(post.title, "title") - guard let comments = post.comments else { - XCTFail("Could not create list of comments") + XCTAssertEqual(decodedPost.id, "postId") + XCTAssertEqual(decodedPost.title, "title") + guard let comments = decodedPost.comments else { + XCTFail("Failed to create list of comments") return } - let state = comments.listProvider.getState() - switch state { + switch comments.listProvider.getState() { case .notLoaded(let associatedId, let associatedField): XCTAssertEqual(associatedId, "postId") - XCTAssertEqual(associatedField, "post") + XCTAssertEqual(associatedField, ChildComment4V2.CodingKeys.post.stringValue) case .loaded: - XCTFail("Should be not loaded") + XCTFail("Should be not loaded with post data") } } - func testPostHasEagerLoadedComments() throws { - // Since we are mocking `graphQLData` below, it does not matter what selection set is contained - // inside the `document` parameter, however for an integration level test, the custom selection set - // should contain two levels, the post fields and the nested comment fields. - let request = GraphQLRequest(document: "", - responseType: ParentPost4V2?.self, - decodePath: "getParentPost4V2") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + func testSaveMultipleThenQueryComments() async throws { + let request = GraphQLRequest.list(ChildComment4V2.self) + let documentString = """ + query ListChildComment4V2s($limit: Int) { + listChildComment4V2s(limit: $limit) { + items { + id + content + createdAt + updatedAt + post { + id + createdAt + title + updatedAt + __typename + } + __typename + } + nextToken + } + } + """ + XCTAssertEqual(request.document, documentString) + guard let variables = request.variables, + let limit = variables["limit"] as? Int else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(limit, 1000) + let decoder = GraphQLResponseDecoder>( + request: request.toOperationRequest(operationType: .query)) let graphQLData: [String: JSONValue] = [ - "getParentPost4V2": [ - "id": "postId", - "title": "title", - "__typename": "ParentPost4V2", - "comments": [ + "\(request.decodePath!)": [ + "items": [ [ "id": "id1", - "content": "content1" + "content": "content1", + "__typename": "ChildComment4V2", + "post": nil ], [ "id": "id2", - "content": "content2" - ] - ] + "content": "content2", + "__typename": "ChildComment4V2", + "post": nil + ], + ], + "nextToken": "nextToken" ] ] - let result = try decoder.decodeToResponseType(graphQLData) - guard let post = result else { - XCTFail("Failed to decode to post") - return - } - XCTAssertEqual(post.id, "postId") - XCTAssertEqual(post.title, "title") - guard let comments = post.comments else { - XCTFail("Could not create list of comments") - return - } - let state = comments.listProvider.getState() - switch state { + let queriedList = try decoder.decodeToResponseType(graphQLData) + switch queriedList.listProvider.getState() { case .notLoaded: - XCTFail("Should be loaded") - case .loaded(let comments): - XCTAssertEqual(comments.count, 2) - let comment1 = comments.first { $0.id == "id1" } - let comment2 = comments.first { $0.id == "id2" } - XCTAssertNotNil(comment1) - XCTAssertNotNil(comment2) + XCTFail("A direct query should have a loaded list") + case .loaded: + break } + XCTAssertEqual(queriedList.count, 2) + let comment1 = queriedList.first { $0.id == "id1" } + let comment2 = queriedList.first { $0.id == "id2" } + XCTAssertNotNil(comment1) + XCTAssertNotNil(comment2) + XCTAssertTrue(queriedList.hasNextPage()) } - func testCommentHasEagerLoadedPost() throws { - // By default, the `.get` for a child model with belongs-to parent creates a nested selection set - // as shown below by `documentStringValue`, so we mock the `graphQLData` with a nested object - // comment containing a post - let request = GraphQLRequest.get(ChildComment4V2.self, byId: "id") - let documentStringValue = """ - query GetChildComment4V2($id: ID!) { - getChildComment4V2(id: $id) { - id - content - createdAt - updatedAt - post { + func testSaveMultipleThenQueryPosts() async throws { + let request = GraphQLRequest.list(ParentPost4V2.self) + let documentString = """ + query ListParentPost4V2s($limit: Int) { + listParentPost4V2s(limit: $limit) { + items { id createdAt title updatedAt __typename } - __typename + nextToken } } """ - XCTAssertEqual(request.document, documentStringValue) - XCTAssertEqual(request.decodePath, "getChildComment4V2") + XCTAssertEqual(request.document, documentString) + guard let variables = request.variables, + let limit = variables["limit"] as? Int else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(limit, 1000) let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) let graphQLData: [String: JSONValue] = [ - "getChildComment4V2": [ + "\(request.decodePath!)": [ + "items": [ + [ + "id": "id1", + "title": "title", + "__typename": "ParentPost4V2", + ], + [ + "id": "id2", + "title": "title", + "__typename": "ParentPost4V2", + ] + ], + "nextToken": "nextToken" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.count, 2) + let post1 = result.first { $0.id == "id1" } + let post2 = result.first { $0.id == "id2" } + XCTAssertNotNil(post1) + XCTAssertNotNil(post2) + } + + func testSaveCommentWithPostThenQueryCommentAndAccessPost() async throws { + let post = ParentPost4V2(title: "title") + let comment = ChildComment4V2(content: "content", post: post) + + let request = GraphQLRequest.create(comment) + guard let variables = request.variables, + let input = variables["input"] as? [String: Any] else { + XCTFail("Missing request.variables input") + return + } + XCTAssertEqual(input["id"] as? String, comment.id) + XCTAssertEqual(input["content"] as? String, comment.content) + XCTAssertEqual(input["postID"] as? String, post.id) + + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + var graphQLData: [String: JSONValue] = [ + "\(request.decodePath!)": [ "id": "id", "content": "content", + "createdAt": nil, + "updatedAt": nil, "post": [ - "id": "postId", + "id": .string("\(post.id)"), "title": "title", + "updatedAt": nil, + "createdAt": nil, "__typename": "ParentPost4V2" ], "__typename": "ChildComment4V2" ] ] - let comment = try decoder.decodeToResponseType(graphQLData) - guard let comment = comment else { - XCTFail("Could not load comment") - return - } + let commentWithEagerLoadedPost = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(commentWithEagerLoadedPost.id, "id") + XCTAssertEqual(commentWithEagerLoadedPost.content, "content") + XCTAssertEqual(commentWithEagerLoadedPost.post?.id, post.id) + } + + func testSaveCommentWithPostThenQueryPostAndAccessComments() async throws { + let post = ParentPost4V2(title: "title") - XCTAssertEqual(comment.id, "id") - XCTAssertEqual(comment.content, "content") - guard let post = comment.post else { - XCTFail("post should be eager loaded") + let request = GraphQLRequest.create(post) + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + let graphQLData: [String: JSONValue] = [ + "\(request.decodePath!)": [ + "id": "postId", + "title": "title", + "createdAt": nil, + "updatedAt": nil, + "__typename": "ParentPost4V2" + ] + ] + + let decodedPost = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(decodedPost.id, "postId") + XCTAssertEqual(decodedPost.title, "title") + guard let comments = decodedPost.comments else { + XCTFail("Failed to create list of comments") return } - XCTAssertEqual(post.id, "postId") - XCTAssertEqual(post.title, "title") + switch comments.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, "postId") + XCTAssertEqual(associatedField, ChildComment4V2.CodingKeys.post.stringValue) + case .loaded: + XCTFail("Should be not loaded with post data") + } } - func testListCommentHasEagerLoadedPost() throws { - // By default, the `.list` for a list of children models with belongs-to parent creates a nested selection set - // as shown below by `documentStringValue`, so we mock the `graphQLData` with a list of nested objects - // comments, each containing a post - let request = GraphQLRequest>.list(ChildComment4V2.self) - let documentStringValue = """ - query ListChildComment4V2s($limit: Int) { - listChildComment4V2s(limit: $limit) { - items { - id - content - createdAt - updatedAt - post { - id - createdAt - title - updatedAt - __typename - } - __typename - } - nextToken - } - } - """ - XCTAssertEqual(request.document, documentStringValue) - XCTAssertEqual(request.decodePath, "listChildComment4V2s") - let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) - + func testSaveMultipleCommentWithPostThenQueryCommentsAndAccessPost() async throws { + let post = ParentPost4V2(title: "title") + let request = GraphQLRequest.list(ChildComment4V2.self) + let decoder = GraphQLResponseDecoder>( + request: request.toOperationRequest(operationType: .query)) let graphQLData: [String: JSONValue] = [ - "listChildComment4V2s": [ + "\(request.decodePath!)": [ "items": [ [ "id": "id1", "content": "content1", - "__typename": "LazyChildComment4V2", + "__typename": "ChildComment4V2", "post": [ - "id": "postId1", - "title": "title1", - "__typename": "LazyParentPost4V2" - ] + "id": .string("\(post.id)"), + "title": "title", + "updatedAt": nil, + "createdAt": nil, + "__typename": "ParentPost4V2" + ], ], - [ - "id": "id2", - "content": "content2", - "__typename": "LazyChildComment4V2", - "post": [ - "id": "postId2", - "title": "title2", - "__typename": "LazyParentPost4V2" - ] - ] - ] + ], + "nextToken": "nextToken" ] ] - - let comments = try decoder.decodeToResponseType(graphQLData) - XCTAssertEqual(comments.count, 2) - guard let comment1 = comments.first(where: { $0.id == "id1" }) else { - XCTFail("Couldn't find comment with `id1`") + let queriedList = try decoder.decodeToResponseType(graphQLData) + guard let comment = queriedList.first else { + XCTFail("Failed to decode to comment") return } - guard let comment2 = comments.first(where: { $0.id == "id2" }) else { - XCTFail("Couldn't find comment with `id2`") - return - } - guard let post1 = comment1.post else { - XCTFail("missing post on comment1") + XCTAssertEqual(comment.post?.id, post.id) + } + + func testSaveMultipleCommentWithPostThenQueryPostAndAccessComments() async throws { + let request = GraphQLRequest.list(ParentPost4V2.self) + let decoder = GraphQLResponseDecoder(request: request.toOperationRequest(operationType: .query)) + let graphQLData: [String: JSONValue] = [ + "\(request.decodePath!)": [ + "items": [ + [ + "id": "id1", + "title": "title", + "__typename": "ParentPost4V2", + ] + ], + "nextToken": "nextToken" + ] + ] + + let result = try decoder.decodeToResponseType(graphQLData) + XCTAssertEqual(result.count, 1) + guard let post = result.first, + let comments = post.comments else { + XCTFail("Failed to decode to one post, with containing comments") return } - XCTAssertEqual(post1.id, "postId1") - XCTAssertEqual(post1.title, "title1") - guard let post2 = comment2.post else { - XCTFail("missing post on comment2") - return + switch comments.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, "id1") + XCTAssertEqual(associatedField, "post") + case .loaded: + XCTFail("Should be in not loaded state") } - XCTAssertEqual(post2.id, "postId2") - XCTAssertEqual(post2.title, "title2") } } - -// MARK: - Models - -public struct ParentPost4V2: Model { - public let id: String - public var title: String - public var comments: List? - public var createdAt: Temporal.DateTime? - public var updatedAt: Temporal.DateTime? - - public init(id: String = UUID().uuidString, - title: String, - comments: List? = []) { - self.init(id: id, - title: title, - comments: comments, - createdAt: nil, - updatedAt: nil) - } - internal init(id: String = UUID().uuidString, - title: String, - comments: List? = [], - createdAt: Temporal.DateTime? = nil, - updatedAt: Temporal.DateTime? = nil) { - self.id = id - self.title = title - self.comments = comments - self.createdAt = createdAt - self.updatedAt = updatedAt - } -} -extension ParentPost4V2 { - // MARK: - CodingKeys - public enum CodingKeys: String, ModelKey { - case id - case title - case comments - case createdAt - case updatedAt - } - - public static let keys = CodingKeys.self - // MARK: - ModelSchema - - public static let schema = defineSchema { model in - let post4V2 = Post4V2.keys - - model.authRules = [ - rule(allow: .public, operations: [.create, .update, .delete, .read]) - ] - - model.pluralName = "Post4V2s" - - model.fields( - .id(), - .field(post4V2.title, is: .required, ofType: .string), - .hasMany(post4V2.comments, is: .optional, ofType: ChildComment4V2.self, associatedWith: ChildComment4V2.keys.post), - .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), - .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) - ) - } -} - -public struct ChildComment4V2: Model { - public let id: String - public var content: String - public var post: ParentPost4V2? - public var createdAt: Temporal.DateTime? - public var updatedAt: Temporal.DateTime? - - public init(id: String = UUID().uuidString, - content: String, - post: ParentPost4V2? = nil) { - self.init(id: id, - content: content, - post: post, - createdAt: nil, - updatedAt: nil) - } - internal init(id: String = UUID().uuidString, - content: String, - post: ParentPost4V2? = nil, - createdAt: Temporal.DateTime? = nil, - updatedAt: Temporal.DateTime? = nil) { - self.id = id - self.content = content - self.post = post - self.createdAt = createdAt - self.updatedAt = updatedAt - } -} - -extension ChildComment4V2 { - // MARK: - CodingKeys - public enum CodingKeys: String, ModelKey { - case id - case content - case post - case createdAt - case updatedAt - } - - public static let keys = CodingKeys.self - // MARK: - ModelSchema - - public static let schema = defineSchema { model in - let comment4V2 = Comment4V2.keys - - model.authRules = [ - rule(allow: .public, operations: [.create, .update, .delete, .read]) - ] - - model.pluralName = "Comment4V2s" - - model.attributes( - .index(fields: ["postID", "content"], name: "byPost4") - ) - - model.fields( - .id(), - .field(comment4V2.content, is: .required, ofType: .string), - .belongsTo(comment4V2.post, is: .optional, ofType: ParentPost4V2.self, targetName: "postID"), - .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), - .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) - ) - } -} diff --git a/AmplifyPlugins/Core/AWSPluginsCore/Model/Support/Model+GraphQL.swift b/AmplifyPlugins/Core/AWSPluginsCore/Model/Support/Model+GraphQL.swift index 72e526dd59..61c4ec47a7 100644 --- a/AmplifyPlugins/Core/AWSPluginsCore/Model/Support/Model+GraphQL.swift +++ b/AmplifyPlugins/Core/AWSPluginsCore/Model/Support/Model+GraphQL.swift @@ -39,7 +39,7 @@ extension Model { for (modelField, modelFieldValue) in fields { let name = modelField.name - + guard let value = modelFieldValue else { // Special case for associated models when the value is `nil`, by setting all of the associated // model's primary key fields (targetNames) to `nil`. @@ -185,9 +185,10 @@ extension Model { } else if let optionalModel = value as? Model?, let modelValue = optionalModel { return modelValue.identifier(schema: modelSchema).values - } else if let lazyModelValue = value as? (any LazyModelMarker) { - print("FOUND A LAZY MODEL \(lazyModelValue)") - //return lazyModelValue.element?.identifier + } else if let lazyModel = value as? (any LazyModelMarker) { + if let modelValue = lazyModel.element { + return modelValue.identifier(schema: modelSchema).values + } } else if let value = value as? [String: JSONValue] { var primaryKeyValues = [Persistable]() for field in modelSchema.primaryKey.fields { @@ -220,7 +221,7 @@ extension Model { if let jsonModel = self as? JSONValueHolder { return jsonModel.jsonValue(for: modelFieldName, modelSchema: modelSchema) ?? nil } else { - return self[modelFieldName] ?? nil + return self[modelFieldName] ?? self["_\(modelFieldName)"] ?? nil } } } diff --git a/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLRequest/GraphQLRequestModelTests.swift b/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLRequest/GraphQLRequestModelTests.swift index 418320230f..e1d2511a2b 100644 --- a/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLRequest/GraphQLRequestModelTests.swift +++ b/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLRequest/GraphQLRequestModelTests.swift @@ -90,7 +90,7 @@ class GraphQLRequestModelTest: XCTestCase { let request = GraphQLRequest.list(Post.self, where: predicate) XCTAssertEqual(document.stringValue, request.document) - XCTAssert(request.responseType == [Post].self) + XCTAssert(request.responseType == List.self) XCTAssertNotNil(request.variables) } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift index 962550379a..44a1ed7144 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift @@ -65,7 +65,11 @@ extension AWSDataStorePlugin: DataStoreBaseBehavior { completion(result) } - storageEngine.save(model, modelSchema: modelSchema, condition: condition, completion: publishingCompletion) + storageEngine.save(model, + modelSchema: modelSchema, + condition: condition, + eagerLoad: true, + completion: publishingCompletion) } public func save(_ model: M, diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift index 074cdd4e15..ca9352f055 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift @@ -9,6 +9,10 @@ import Foundation import Amplify public struct DataStoreModelDecoder: ModelProviderDecoder { + + // TODO: have some sort of priority on the ModelProviderDecoder + // to indicate run 1 then run 2 + public static func shouldDecode(modelType: ModelType.Type, decoder: Decoder) -> Bool { guard let json = try? JSONValue(from: decoder) else { return false diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift index e13e92f057..34dd84d805 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift @@ -12,7 +12,7 @@ import Combine public class DataStoreModelProvider: ModelProvider { enum LoadedState { - case notLoaded(identifier: String) + case notLoaded(identifiers: [String: String]) case loaded(model: ModelType?) } @@ -23,15 +23,21 @@ public class DataStoreModelProvider: ModelProvider { } init(identifier: String) { - self.loadedState = .notLoaded(identifier: identifier) + let identifiers: [String: String] = ["id": identifier] + self.loadedState = .notLoaded(identifiers: identifiers) } // MARK: - APIs public func load() async throws -> ModelType? { switch loadedState { - case .notLoaded(let identifier): - let model = try await Amplify.DataStore.query(ModelType.self, byId: identifier) + case .notLoaded(let identifiers): + // TODO: identifers should allow us to pass in just the `id` or the composite key ? + // or directly query against using the `@@primaryKey` ? + guard let identifier = identifiers.first else { + return nil + } + let model = try await Amplify.DataStore.query(ModelType.self, byId: identifier.value) self.loadedState = .loaded(model: model) return model case .loaded(let model): @@ -41,8 +47,8 @@ public class DataStoreModelProvider: ModelProvider { public func getState() -> ModelProviderState { switch loadedState { - case .notLoaded(let identifier): - return .notLoaded(identifier: identifier) + case .notLoaded(let identifiers): + return .notLoaded(identifiers: identifiers) case .loaded(let model): return .loaded(model) } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift index e1a77222f7..798437b6f9 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift @@ -15,10 +15,12 @@ protocol ModelStorageBehavior { func save(_ model: M, modelSchema: ModelSchema, condition: QueryPredicate?, + eagerLoad: Bool, completion: @escaping DataStoreCallback) func save(_ model: M, condition: QueryPredicate?, + eagerLoad: Bool, completion: @escaping DataStoreCallback) @available(*, deprecated, message: "Use delete(:modelSchema:withIdentifier:predicate:completion") diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift index f213c7c0f3..53a5cd886f 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift @@ -62,7 +62,7 @@ extension Model { let modelFields = fields ?? modelSchema.sortedFields let values: [Binding?] = modelFields.map { field in - let existingFieldOptionalValue: Any?? + var existingFieldOptionalValue: Any?? // self[field.name] subscript accessor or jsonValue() returns an Any??, we need to do a few things: // - `guard` to make sure the field name exists on the model @@ -75,8 +75,14 @@ extension Model { existingFieldOptionalValue = jsonModel.jsonValue(for: field.name, modelSchema: modelSchema) } else { existingFieldOptionalValue = self[field.name] + // Additional attempt to get the internal data, like "_post" + // TODO: alternatively, check if association or not + if existingFieldOptionalValue == nil { + let internalFieldName = "_\(field.name)" + existingFieldOptionalValue = self[internalFieldName] + } } - + guard let existingFieldValue = existingFieldOptionalValue else { return nil } @@ -107,7 +113,6 @@ extension Model { if let associatedModelValue = value as? Model { return associatedModelValue.identifier } else if let associatedLazyModel = value as? (any LazyModelMarker) { - print("FOUND A LAZY MODEL \(associatedLazyModel) id: \(associatedLazyModel.element?.identifier)") return associatedLazyModel.element?.identifier } else if let associatedModelJSON = value as? [String: JSONValue] { return associatedPrimaryKeyValue(fromJSON: associatedModelJSON, diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift index a9aca91b46..957e42de32 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift @@ -89,16 +89,12 @@ extension Statement: StatementModelConvertible { let columnMapping = statement.metadata.columnMapping let modelDictionary = ([:] as ModelValues).mutableCopy() var skipColumns = Set() - var lazyLoadColumnValues = [(String, Binding?)]() - print(row) + var foreignKeyValues = [(String, Binding?)]() for (index, value) in row.enumerated() { let column = columnNames[index] guard let (schema, field) = columnMapping[column] else { - logger.debug(""" - A column named \(column) was found in the result set but no field on - \(modelSchema.name) could be found with that name and it will be ignored. - """) - lazyLoadColumnValues.append((column, value)) + logger.debug("[LazyLoad] Foreign key `\(column)` was found in the SQL result set with value: \(value)") + foreignKeyValues.append((column, value)) continue } @@ -172,25 +168,23 @@ extension Statement: StatementModelConvertible { // modelDictionary["post"]["blog"] = nil let sortedColumns = skipColumns.sorted(by: { $0.count > $1.count }) for skipColumn in sortedColumns { - print("Skipping column: \(skipColumn)") modelDictionary.updateValue(nil, forKeyPath: skipColumn) } modelDictionary["__typename"] = modelSchema.name - // `lazyloadColumnValues` are all foreign keys that can be added to the object for lazy loading - // Once lazy loading is enabled, this `lazyloadColumnValues` will be populated. + // `foreignKeyValues` are all foreign keys and their values that can be added to the object for lazy loading + // belongs to associations. if !eagerLoad { - for lazyLoadColumnValue in lazyLoadColumnValues { - let foreignColumnName = lazyLoadColumnValue.0 + for foreignKeyValue in foreignKeyValues { + let foreignColumnName = foreignKeyValue.0 if let foreignModel = modelSchema.foreignKeys.first(where: { modelField in modelField.sqlName == foreignColumnName }) { - modelDictionary[foreignModel.name] = lazyLoadColumnValue.1 + modelDictionary[foreignModel.name] = foreignKeyValue.1 } } } - // swiftlint:disable:next force_cast return modelDictionary as! ModelValues } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift index b228f67d7d..bd48f64e78 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift @@ -126,11 +126,22 @@ final class SQLiteStorageEngineAdapter: StorageEngineAdapter { } // MARK: - Save - func save(_ model: M, condition: QueryPredicate? = nil, completion: @escaping DataStoreCallback) { - save(model, modelSchema: model.schema, condition: condition, completion: completion) + func save(_ model: M, + condition: QueryPredicate? = nil, + eagerLoad: Bool = true, + completion: @escaping DataStoreCallback) { + save(model, + modelSchema: model.schema, + condition: condition, + eagerLoad: eagerLoad, + completion: completion) } - func save(_ model: M, modelSchema: ModelSchema, condition: QueryPredicate? = nil, completion: DataStoreCallback) { + func save(_ model: M, + modelSchema: ModelSchema, + condition: QueryPredicate? = nil, + eagerLoad: Bool = true, + completion: DataStoreCallback) { guard let connection = connection else { completion(.failure(DataStoreError.nilSQLiteConnection())) return @@ -175,7 +186,9 @@ final class SQLiteStorageEngineAdapter: StorageEngineAdapter { } // load the recent saved instance and pass it back to the callback - query(modelType, modelSchema: modelSchema, predicate: model.identifier(schema: modelSchema).predicate) { + query(modelType, modelSchema: modelSchema, + predicate: model.identifier(schema: modelSchema).predicate, + eagerLoad: eagerLoad) { switch $0 { case .success(let result): if let saved = result.first { diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/StorageEngine.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/StorageEngine.swift index 34d745f32a..ceeb5f92be 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/StorageEngine.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/StorageEngine.swift @@ -170,6 +170,7 @@ final class StorageEngine: StorageEngineBehavior { public func save(_ model: M, modelSchema: ModelSchema, condition: QueryPredicate? = nil, + eagerLoad: Bool = true, completion: @escaping DataStoreCallback) { // TODO: Refactor this into a proper request/result where the result includes metadata like the derived @@ -218,11 +219,19 @@ final class StorageEngine: StorageEngineBehavior { storageAdapter.save(model, modelSchema: modelSchema, condition: condition, + eagerLoad: eagerLoad, completion: wrappedCompletion) } - func save(_ model: M, condition: QueryPredicate? = nil, completion: @escaping DataStoreCallback) { - save(model, modelSchema: model.schema, condition: condition, completion: completion) + func save(_ model: M, + condition: QueryPredicate? = nil, + eagerLoad: Bool = true, + completion: @escaping DataStoreCallback) { + save(model, + modelSchema: model.schema, + condition: condition, + eagerLoad: eagerLoad, + completion: completion) } @available(*, deprecated, message: "Use delete(:modelSchema:withIdentifier:predicate:completion") diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift index f02466dd27..8fffaaeaf9 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift @@ -207,7 +207,7 @@ final class InitialSyncOperation: AsynchronousOperation { } let syncMetadata = ModelSyncMetadata(id: modelSchema.name, lastSync: lastSyncTime) - storageAdapter.save(syncMetadata, condition: nil) { result in + storageAdapter.save(syncMetadata, condition: nil, eagerLoad: true) { result in switch result { case .failure(let dataStoreError): self.finish(result: .failure(dataStoreError)) diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift index 9e88d5c101..35e0203ab8 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift @@ -224,7 +224,7 @@ extension AWSMutationDatabaseAdapter: MutationEventIngester { if nextEventPromise.get() != nil { eventToPersist.inProcess = true } - storageAdapter.save(eventToPersist, condition: nil) { result in + storageAdapter.save(eventToPersist, condition: nil, eagerLoad: true) { result in switch result { case .failure(let dataStoreError): self.log.verbose("\(#function): Error saving mutation event: \(dataStoreError)") diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift index 8d58678059..0ed3b5c0b8 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift @@ -46,7 +46,7 @@ extension AWSMutationDatabaseAdapter: MutationEventSource { completion: @escaping DataStoreCallback) { var inProcessEvent = mutationEvent inProcessEvent.inProcess = true - storageAdapter.save(inProcessEvent, condition: nil, completion: completion) + storageAdapter.save(inProcessEvent, condition: nil, eagerLoad: true, completion: completion) } } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift index 5473c3b9e9..0ecdd71523 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift @@ -43,7 +43,7 @@ final class MutationEventClearState { for mutationEvent in mutationEvents { var inProcessEvent = mutationEvent inProcessEvent.inProcess = false - storageAdapter.save(inProcessEvent, condition: nil, completion: { result in + storageAdapter.save(inProcessEvent, condition: nil, eagerLoad: true, completion: { result in switch result { case .success: numMutationEventsUpdated += 1 diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift index 88285b2410..e4200ebc6f 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift @@ -360,7 +360,7 @@ class ProcessMutationErrorFromCloudOperation: AsynchronousOperation { private func saveMetadata(storageAdapter: StorageEngineAdapter, inProcessModel: MutationSync) { log.verbose(#function) - storageAdapter.save(inProcessModel.syncMetadata, condition: nil) { result in + storageAdapter.save(inProcessModel.syncMetadata, condition: nil, eagerLoad: true) { result in switch result { case .failure(let dataStoreError): let error = DataStoreError.unknown("Save metadata failed \(dataStoreError)", "") diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift index c4d2a6d6a1..ba5cce146f 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift @@ -403,7 +403,7 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { return } - storageAdapter.save(inProcessModel.syncMetadata, condition: nil) { result in + storageAdapter.save(inProcessModel.syncMetadata, condition: nil, eagerLoad: true) { result in switch result { case .failure(let dataStoreError): self.notifyDropped(error: dataStoreError) diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/Support/MutationEvent+Extensions.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/Support/MutationEvent+Extensions.swift index be53a6ab4e..8baf9ad465 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/Support/MutationEvent+Extensions.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/Support/MutationEvent+Extensions.swift @@ -58,7 +58,7 @@ extension MutationEvent { return } - storageAdapter.save(reconciledEvent, condition: nil) { result in + storageAdapter.save(reconciledEvent, condition: nil, eagerLoad: true) { result in switch result { case .failure(let dataStoreError): completion(.failure(dataStoreError)) diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsBase.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsBase.swift index 6e7cb5a448..abebf0a5e8 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsBase.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsBase.swift @@ -54,9 +54,10 @@ class StorageEngineTestsBase: XCTestCase { return saveResult } - func saveAsync(_ model: M) async throws -> M { + @discardableResult + func saveAsync(_ model: M, eagerLoad: Bool = true) async throws -> M { try await withCheckedThrowingContinuation { continuation in - storageEngine.save(model) { sResult in + storageEngine.save(model, eagerLoad: eagerLoad) { sResult in continuation.resume(with: sResult) } } diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift index a2de0d75d4..64ba434bec 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift @@ -14,12 +14,11 @@ import XCTest @testable import AmplifyTestCommon @testable import AWSDataStorePlugin - -final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase { +final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase, SharedTestCasesPostComment4V2 { override func setUp() { super.setUp() - Amplify.Logging.logLevel = .warn + Amplify.Logging.logLevel = .verbose let validAPIPluginKey = "MockAPICategoryPlugin" let validAuthPluginKey = "MockAuthCategoryPlugin" @@ -52,77 +51,92 @@ final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase { } } - func testQueryComment() async throws { + func testSaveCommentThenQueryComment() async throws { let comment = LazyChildComment4V2(content: "content") - _ = try await saveAsync(comment) - - guard (try await queryAsync(LazyChildComment4V2.self, - byIdentifier: comment.id)) != nil else { + let savedComment = try await saveAsync(comment) + XCTAssertEqual(savedComment.id, comment.id) + switch savedComment._post.modelProvider.getState() { + case .notLoaded: + XCTFail("should be loaded, with `nil` element") + case .loaded(let element): + XCTAssertNil(element) + } + guard let queriedComment = try await queryAsync(LazyChildComment4V2.self, + byIdentifier: comment.id, + eagerLoad: true) else { XCTFail("Failed to query saved comment") return } + XCTAssertEqual(queriedComment.id, queriedComment.id) + switch queriedComment._post.modelProvider.getState() { + case .notLoaded: + XCTFail("should be loaded, with `nil` element") + case .loaded(let element): + XCTAssertNil(element) + } } - func testQueryPost() async throws { + func testSavePostThenQueryPost() async throws { let post = LazyParentPost4V2(title: "title") - _ = try await saveAsync(post) + let savedPost = try await saveAsync(post) + XCTAssertEqual(savedPost.id, post.id) - guard (try await queryAsync(LazyParentPost4V2.self, - byIdentifier: post.id)) != nil else { + guard let queriedPost = try await queryAsync(LazyParentPost4V2.self, + byIdentifier: post.id, + eagerLoad: true) else { XCTFail("Failed to query saved post") return } + XCTAssertEqual(queriedPost.id, queriedPost.id) + switch queriedPost.comments?.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, post.id) + XCTAssertEqual(associatedField, LazyChildComment4V2.CodingKeys.post.stringValue) + case .loaded: + XCTFail("Should be not loaded") + default: + XCTFail("missing comments") + } } - func testQueryListComments() async throws { - _ = try await saveAsync(LazyChildComment4V2(content: "content")) - _ = try await saveAsync(LazyChildComment4V2(content: "content")) + func testSaveMultipleThenQueryComments() async throws { + try await saveAsync(LazyChildComment4V2(content: "content")) + try await saveAsync(LazyChildComment4V2(content: "content")) - let comments = try await queryAsync(LazyChildComment4V2.self) + let comments = try await queryAsync(LazyChildComment4V2.self, + eagerLoad: true) XCTAssertEqual(comments.count, 2) } - func testQueryListPosts() async throws { - _ = try await saveAsync(LazyParentPost4V2(title: "title")) - _ = try await saveAsync(LazyParentPost4V2(title: "title")) + func testSaveMultipleThenQueryPosts() async throws { + try await saveAsync(LazyParentPost4V2(title: "title")) + try await saveAsync(LazyParentPost4V2(title: "title")) - let comments = try await queryAsync(LazyParentPost4V2.self) + let comments = try await queryAsync(LazyParentPost4V2.self, + eagerLoad: true) XCTAssertEqual(comments.count, 2) } - func testPostHasLazyLoadedComments() async throws { - let post = LazyParentPost4V2(title: "title") - _ = try await saveAsync(post) - let comment = LazyChildComment4V2(content: "content", post: post) - _ = try await saveAsync(comment) - - guard let queriedPost = try await queryAsync(LazyParentPost4V2.self, - byIdentifier: post.id) else { - XCTFail("Failed to query saved post") - return - } - XCTAssertEqual(queriedPost.id, post.id) - guard let comments = queriedPost.comments else { - XCTFail("Failed to get comments from queried post") - return - } - - switch comments.listProvider.getState() { - case .notLoaded(let associatedId, let associatedField): - XCTAssertEqual(associatedId, post.id) - XCTAssertEqual(associatedField, "post") - case .loaded(let comments): - XCTFail("Should not be loaded") - } - } - - func testCommentHasEagerLoadedPost_InsertUpdateSelect() async throws { + func testCommentWithPost_TranslateToStorageValues() async throws { let post = LazyParentPost4V2(id: "postId", title: "title") _ = try await saveAsync(post) var comment = LazyChildComment4V2(content: "content", post: post) + // Model.sqlValues tesitng + let sqlValues = comment.sqlValues(for: LazyChildComment4V2.schema.columns, + modelSchema: LazyChildComment4V2.schema) + XCTAssertEqual(sqlValues[0] as? String, comment.id) + XCTAssertEqual(sqlValues[1] as? String, comment.content) + XCTAssertNil(sqlValues[2]) // createdAt + XCTAssertNil(sqlValues[3]) // updatedAt + XCTAssertEqual(sqlValues[4] as? String, post.id) + // Insert let insertStatement = InsertStatement(model: comment, modelSchema: LazyChildComment4V2.schema) + XCTAssertEqual(insertStatement.variables[0] as? String, comment.id) + XCTAssertEqual(insertStatement.variables[1] as? String, comment.content) + XCTAssertNil(insertStatement.variables[2]) // createdAt + XCTAssertNil(insertStatement.variables[3]) // updatedAt XCTAssertEqual(insertStatement.variables[4] as? String, post.id) _ = try connection.prepare(insertStatement.stringValue).run(insertStatement.variables) @@ -133,7 +147,7 @@ final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase { condition: nil) _ = try connection.prepare(updateStatement.stringValue).run(updateStatement.variables) - // Select + // Select with eagerLoad true let selectStatement = SelectStatement(from: LazyChildComment4V2.schema, predicate: field("id").eq(comment.id), sort: nil, @@ -153,11 +167,7 @@ final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase { } XCTAssertEqual(comment.content, "updatedContent") - guard let eagerLoadedPost = comment.post else { - XCTFail("post should be decoded") - return - } - switch eagerLoadedPost.modelProvider.getState() { + switch comment._post.modelProvider.getState() { case .notLoaded: XCTFail("Should have been loaded") case .loaded(let post): @@ -168,160 +178,130 @@ final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase { XCTAssertEqual(post.id, "postId") XCTAssertEqual(post.title, "title") } - } - - func testCommentHasEagerLoadedPost_StorageEngineAdapterSQLite() async throws { - let post = LazyParentPost4V2(id: "postId", title: "title") - _ = try await saveAsync(post) - let comment = LazyChildComment4V2(content: "content", post: post) - _ = try await saveAsync(comment) - guard let queriedComment = try await queryStorageAdapter(LazyChildComment4V2.self, - byIdentifier: comment.id) else { - XCTFail("Failed to query saved comment") + // Select with eagerLoad false + let selectStatement2 = SelectStatement(from: LazyChildComment4V2.schema, + predicate: field("id").eq(comment.id), + sort: nil, + paginationInput: nil, + eagerLoad: false) + let rows2 = try connection.prepare(selectStatement2.stringValue).run(selectStatement2.variables) + _ = try rows2.convertToModelValues(to: LazyChildComment4V2.self, + withSchema: LazyChildComment4V2.schema, + using: selectStatement2, + eagerLoad: false) + let comments2 = try rows.convert(to: LazyChildComment4V2.self, + withSchema: LazyChildComment4V2.schema, + using: selectStatement2, + eagerLoad: false) + XCTAssertEqual(comments.count, 1) + guard let comment2 = comments2.first else { + XCTFail("Should retrieve single comment") return } - XCTAssertEqual(queriedComment.id, comment.id) - guard let eagerLoadedPost = queriedComment.post else { - XCTFail("post should be decoded") - return + + XCTAssertEqual(comment2.content, "updatedContent") + switch comment2._post.modelProvider.getState() { + case .notLoaded(let identifiers): + XCTAssertEqual(identifiers["id"], "postId") + case .loaded: + XCTFail("Should be not loaded") } - switch eagerLoadedPost.modelProvider.getState() { + } + + func testSaveCommentWithPostThenQueryCommentAndAccessPost() async throws { + let post = LazyParentPost4V2(title: "title") + try await saveAsync(post) + let comment = LazyChildComment4V2(content: "content", post: post) + let savedComment = try await saveAsync(comment) + + // The post should be eager loaded by default on a save + switch savedComment._post.modelProvider.getState() { case .notLoaded: - XCTFail("Should have been loaded") - case .loaded(let post): - guard let post = post else { - XCTFail("Loaded with no post") + XCTFail("eager loaded post should be loaded") + case .loaded(let element): + guard let loadedPost = element else { + XCTFail("post is missing from the loaded state of LazyModel") return } - XCTAssertEqual(post.id, "postId") - XCTAssertEqual(post.title, "title") + XCTAssertEqual(loadedPost.id, post.id) } - } - - // Loading the comments should also eager load the post since `eagerLoad` is true by default. `eagerLoad` - // controls whether nested data is fetched using the SQL join statements. - func testCommentHasEagerLoadedPost_StorageEngine() async throws { - let post = LazyParentPost4V2(id: "postId", title: "title") - _ = try await saveAsync(post) - let comment = LazyChildComment4V2(content: "content", post: post) - _ = try await saveAsync(comment) - guard let queriedComment = try await queryAsync(LazyChildComment4V2.self, - byIdentifier: comment.id) else { + // The query with eagerLoad should load the post + guard let queriedCommentEagerLoadedPost = try await queryAsync(LazyChildComment4V2.self, + byIdentifier: comment.id, + eagerLoad: true) else { XCTFail("Failed to query saved comment") return } - XCTAssertEqual(queriedComment.id, comment.id) - guard let eagerLoadedPost = queriedComment.post else { - XCTFail("post should be decoded") - return - } - switch eagerLoadedPost.modelProvider.getState() { + switch queriedCommentEagerLoadedPost._post.modelProvider.getState() { case .notLoaded: - XCTFail("Should have been loaded") - case .loaded(let post): - guard let post = post else { - XCTFail("Loaded with no post") + XCTFail("eager loaded post should be loaded") + case .loaded(let element): + guard let loadedPost = element else { + XCTFail("post is missing from the loaded state of LazyModel") return } - XCTAssertEqual(post.id, "postId") - XCTAssertEqual(post.title, "title") + XCTAssertEqual(loadedPost.id, post.id) } - } - func testCommentHasLazyLoadedPost_InsertUpdateSelect() async throws { - let eagerLoad = false - let post = LazyParentPost4V2(id: "postId", title: "title") - _ = try await saveAsync(post) - var comment = LazyChildComment4V2(content: "content", post: post) - - // Insert - let insertStatement = InsertStatement(model: comment, modelSchema: LazyChildComment4V2.schema) - XCTAssertEqual(insertStatement.variables[4] as? String, post.id) - _ = try connection.prepare(insertStatement.stringValue).run(insertStatement.variables) - - // Update - comment.content = "updatedContent" - let updateStatement = UpdateStatement(model: comment, - modelSchema: LazyChildComment4V2.schema, - condition: nil) - _ = try connection.prepare(updateStatement.stringValue).run(updateStatement.variables) - - // Select - let selectStatement = SelectStatement(from: LazyChildComment4V2.schema, - predicate: field("id").eq(comment.id), - sort: nil, - paginationInput: nil, - eagerLoad: eagerLoad) - let rows = try connection.prepare(selectStatement.stringValue).run(selectStatement.variables) - let modelJSON = try rows.convertToModelValues(to: LazyChildComment4V2.self, - withSchema: LazyChildComment4V2.schema, - using: selectStatement, - eagerLoad: eagerLoad) - let comments = try rows.convert(to: LazyChildComment4V2.self, - withSchema: LazyChildComment4V2.schema, - using: selectStatement, - eagerLoad: eagerLoad) - XCTAssertEqual(comments.count, 1) - guard let comment = comments.first else { - XCTFail("Should retrieve single comment") - return - } - - XCTAssertEqual(comment.content, "updatedContent") - guard let lazyLoadedPost = comment.post else { - XCTFail("post should be decoded") + // The query with eagerLoad false should create a not loaded post for lazy loading + guard let queriedCommentLazyLoadedPost = try await queryAsync(LazyChildComment4V2.self, + byIdentifier: comment.id, + eagerLoad: false) else { + XCTFail("Failed to query saved comment") return } - switch lazyLoadedPost.modelProvider.getState() { - case .notLoaded(let id): - XCTAssertEqual(id, "postId") + switch queriedCommentLazyLoadedPost._post.modelProvider.getState() { + case .notLoaded(let identifiers): + XCTAssertEqual(identifiers["id"], post.id) case .loaded: - XCTFail("Should be not loaded") + XCTFail("lazy loaded post should be not loaded") } } - // Loading the comments should lazy load the post when `eagerLoad` is explicitly set to false. This will stop the - // SQL join statements from being added and only store the - func testCommentHasLazyLoadPost() async throws { - let post = LazyParentPost4V2(id: "postId", title: "title") - _ = try await saveAsync(post) + func testSaveCommentWithPostThenQueryPostAndAccessComments() async throws { + let post = LazyParentPost4V2(title: "title") + try await saveAsync(post) let comment = LazyChildComment4V2(content: "content", post: post) - _ = try await saveAsync(comment) + try await saveAsync(comment) - guard let queriedComment = try await queryAsync(LazyChildComment4V2.self, - byIdentifier: comment.id, - eagerLoad: false) else { - XCTFail("Failed to query saved comment") + guard let queriedPost = try await queryAsync(LazyParentPost4V2.self, + byIdentifier: post.id, + eagerLoad: true) else { + XCTFail("Failed to query saved post") return } - XCTAssertEqual(queriedComment.id, comment.id) - guard let lazyLoadedPost = queriedComment.post else { - XCTFail("post should be decoded") + XCTAssertEqual(queriedPost.id, post.id) + guard let comments = queriedPost.comments else { + XCTFail("Failed to get comments from queried post") return } - switch lazyLoadedPost.modelProvider.getState() { - case .notLoaded(let id): - XCTAssertEqual(id, "postId") + + switch comments.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, post.id) + XCTAssertEqual(associatedField, "post") case .loaded: - XCTFail("Should be not loaded") + XCTFail("Should not be loaded") } } - func testListCommentHasEagerLoadedPost() async throws { + func testSaveMultipleCommentWithPostThenQueryCommentsAndAccessPost() async throws { let post1 = LazyParentPost4V2(id: "postId1", title: "title1") - _ = try await saveAsync(post1) + try await saveAsync(post1) let comment1 = LazyChildComment4V2(id: "id1", content: "content", post: post1) - _ = try await saveAsync(comment1) + try await saveAsync(comment1) let post2 = LazyParentPost4V2(id: "postId2", title: "title2") - _ = try await saveAsync(post2) - let comment2 = LazyChildComment4V2(id: "id2", content: "content", post: post2) - _ = try await saveAsync(comment2) - - let comments = try await queryAsync(LazyChildComment4V2.self, + try await saveAsync(post2) + var comment2 = LazyChildComment4V2(id: "id2", content: "content") + comment2.setPost(post2) + try await saveAsync(comment2) + + // Query with eagerLoad true + var comments = try await queryAsync(LazyChildComment4V2.self, eagerLoad: true) - + XCTAssertEqual(comments.count, 2) guard let comment1 = comments.first(where: { $0.id == "id1" }) else { XCTFail("Couldn't find comment with `id1`") @@ -331,44 +311,25 @@ final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase { XCTFail("Couldn't find comment with `id2`") return } - guard let post1 = comment1.post else { - XCTFail("missing post on comment1") - return - } - guard let post2 = comment2.post else { - XCTFail("missing post on comment2") - return - } - - switch post1.modelProvider.getState() { + switch comment1._post.modelProvider.getState() { case .notLoaded: XCTFail("Should be loaded") case .loaded(let post): XCTAssertEqual(post!.id, "postId1") XCTAssertEqual(post!.title, "title1") } - switch post2.modelProvider.getState() { + switch comment2._post.modelProvider.getState() { case .notLoaded: XCTFail("Should be loaded") case .loaded(let post): XCTAssertEqual(post!.id, "postId2") XCTAssertEqual(post!.title, "title2") } - } - - func testListCommentHasLazyLoadedPost() async throws { - let post1 = LazyParentPost4V2(id: "postId1", title: "title1") - _ = try await saveAsync(post1) - let comment1 = LazyChildComment4V2(id: "id1", content: "content", post: post1) - _ = try await saveAsync(comment1) - let post2 = LazyParentPost4V2(id: "postId2", title: "title2") - _ = try await saveAsync(post2) - let comment2 = LazyChildComment4V2(id: "id2", content: "content", post: post2) - _ = try await saveAsync(comment2) - let comments = try await queryAsync(LazyChildComment4V2.self, + // Query with eagerLoad false + comments = try await queryAsync(LazyChildComment4V2.self, eagerLoad: false) - + XCTAssertEqual(comments.count, 2) guard let comment1 = comments.first(where: { $0.id == "id1" }) else { XCTFail("Couldn't find comment with `id1`") @@ -378,153 +339,64 @@ final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase { XCTFail("Couldn't find comment with `id2`") return } - guard let post1 = comment1.post else { - XCTFail("missing post on comment1") - return - } - guard let post2 = comment2.post else { - XCTFail("missing post on comment2") - return - } - - switch post1.modelProvider.getState() { - case .notLoaded(let identifier): - XCTAssertEqual(identifier, "postId1") + switch comment1._post.modelProvider.getState() { + case .notLoaded(let identifiers): + XCTAssertEqual(identifiers["id"], "postId1") case .loaded: XCTFail("Should be not loaded") } - switch post2.modelProvider.getState() { - case .notLoaded(let identifier): - XCTAssertEqual(identifier, "postId2") + switch comment2._post.modelProvider.getState() { + case .notLoaded(let identifiers): + XCTAssertEqual(identifiers["id"], "postId2") case .loaded: XCTFail("Should be not loaded") } } -} - -// MARK: - Models - -public struct LazyParentPost4V2: Model { - public let id: String - public var title: String - public var comments: List? - public var createdAt: Temporal.DateTime? - public var updatedAt: Temporal.DateTime? - - public init(id: String = UUID().uuidString, - title: String, - comments: List? = []) { - self.init(id: id, - title: title, - comments: comments, - createdAt: nil, - updatedAt: nil) - } - internal init(id: String = UUID().uuidString, - title: String, - comments: List? = [], - createdAt: Temporal.DateTime? = nil, - updatedAt: Temporal.DateTime? = nil) { - self.id = id - self.title = title - self.comments = comments - self.createdAt = createdAt - self.updatedAt = updatedAt - } -} -extension LazyParentPost4V2 { - // MARK: - CodingKeys - public enum CodingKeys: String, ModelKey { - case id - case title - case comments - case createdAt - case updatedAt - } - public static let keys = CodingKeys.self - // MARK: - ModelSchema - - public static let schema = defineSchema { model in - let post4V2 = Post4V2.keys + func testSaveMultipleCommentWithPostThenQueryPostAndAccessComments() async throws { + let post1 = LazyParentPost4V2(id: "postId1", title: "title1") + try await saveAsync(post1) + let comment1 = LazyChildComment4V2(id: "id1", content: "content", post: post1) + try await saveAsync(comment1) + let post2 = LazyParentPost4V2(id: "postId2", title: "title2") + try await saveAsync(post2) + let comment2 = LazyChildComment4V2(id: "id2", content: "content", post: post2) + try await saveAsync(comment2) - model.authRules = [ - rule(allow: .public, operations: [.create, .update, .delete, .read]) - ] + var posts = try await queryAsync(LazyParentPost4V2.self) + XCTAssertEqual(posts.count, 2) + guard let postId1 = posts.first(where: { $0.id == "postId1" }) else { + XCTFail("Couldn't find post with `postId1`") + return + } + guard let postId2 = posts.first(where: { $0.id == "postId2" }) else { + XCTFail("Couldn't find post with `postId2`") + return + } + guard let comments1 = postId1.comments else { + XCTFail("Failed to get comments from post1") + return + } - model.pluralName = "Post4V2s" + switch comments1.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, post1.id) + XCTAssertEqual(associatedField, "post") + case .loaded: + XCTFail("Should not be loaded") + } - model.fields( - .id(), - .field(post4V2.title, is: .required, ofType: .string), - .hasMany(post4V2.comments, is: .optional, ofType: LazyChildComment4V2.self, associatedWith: LazyChildComment4V2.keys.post), - .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), - .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) - ) - } -} - -public struct LazyChildComment4V2: Model { - public let id: String - public var content: String - public var post: LazyModel? - public var createdAt: Temporal.DateTime? - public var updatedAt: Temporal.DateTime? - - public init(id: String = UUID().uuidString, - content: String, - post: LazyParentPost4V2? = nil) { - self.init(id: id, - content: content, - post: post, - createdAt: nil, - updatedAt: nil) - } - internal init(id: String = UUID().uuidString, - content: String, - post: LazyParentPost4V2? = nil, - createdAt: Temporal.DateTime? = nil, - updatedAt: Temporal.DateTime? = nil) { - self.id = id - self.content = content - self.post = LazyModel(element: post) - self.createdAt = createdAt - self.updatedAt = updatedAt + guard let comments2 = postId2.comments else { + XCTFail("Failed to get comments from post2") + return + } + switch comments2.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, post2.id) + XCTAssertEqual(associatedField, "post") + case .loaded: + XCTFail("Should not be loaded") + } } } -extension LazyChildComment4V2 { - // MARK: - CodingKeys - public enum CodingKeys: String, ModelKey { - case id - case content - case post - case createdAt - case updatedAt - } - - public static let keys = CodingKeys.self - // MARK: - ModelSchema - - public static let schema = defineSchema { model in - let comment4V2 = Comment4V2.keys - - model.authRules = [ - rule(allow: .public, operations: [.create, .update, .delete, .read]) - ] - - model.pluralName = "Comment4V2s" - - model.attributes( - .index(fields: ["postID", "content"], name: "byPost4") - ) - - model.fields( - .id(), - .field(comment4V2.content, is: .required, ofType: .string), - .belongsTo(comment4V2.post, is: .optional, ofType: LazyParentPost4V2.self, targetName: "postID"), - .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), - .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) - ) - } -} diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsPostComment4V2Tests.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsPostComment4V2Tests.swift index f971aed83e..e6597c45b5 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsPostComment4V2Tests.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsPostComment4V2Tests.swift @@ -13,7 +13,7 @@ import XCTest @testable import AmplifyTestCommon @testable import AWSDataStorePlugin -final class StorageEngineTestsPostComment4V2Tests: StorageEngineTestsBase { +final class StorageEngineTestsPostComment4V2Tests: StorageEngineTestsBase, SharedTestCasesPostComment4V2 { override func setUp() { super.setUp() @@ -50,79 +50,108 @@ final class StorageEngineTestsPostComment4V2Tests: StorageEngineTestsBase { } } - func testQueryComment() async throws { + func testSaveCommentThenQueryComment() async throws { let comment = ChildComment4V2(content: "content") - _ = try await saveAsync(comment) + let savedComment = try await saveAsync(comment) + XCTAssertEqual(savedComment.id, comment.id) - guard (try await queryAsync(ChildComment4V2.self, - byIdentifier: comment.id)) != nil else { + guard let queriedComment = try await queryAsync(ChildComment4V2.self, + byIdentifier: comment.id, + eagerLoad: true) else { XCTFail("Failed to query saved comment") return } + XCTAssertEqual(queriedComment.id, comment.id) } - func testQueryPost() async throws { + func testSavePostThenQueryPost() async throws { let post = ParentPost4V2(title: "title") - _ = try await saveAsync(post) + let savedPost = try await saveAsync(post) + XCTAssertEqual(savedPost.id, post.id) - guard (try await queryAsync(ParentPost4V2.self, - byIdentifier: post.id)) != nil else { + guard let queriedPost = try await queryAsync(ParentPost4V2.self, + byIdentifier: post.id, + eagerLoad: true) else { XCTFail("Failed to query saved post") return } + XCTAssertEqual(queriedPost.id, post.id) } - func testQueryListComments() async throws { - _ = try await saveAsync(ChildComment4V2(content: "content")) - _ = try await saveAsync(ChildComment4V2(content: "content")) + func testSaveMultipleThenQueryComments() async throws { + try await saveAsync(ChildComment4V2(content: "content")) + try await saveAsync(ChildComment4V2(content: "content")) - let comments = try await queryAsync(ChildComment4V2.self) + let comments = try await queryAsync(ChildComment4V2.self, eagerLoad: true) XCTAssertEqual(comments.count, 2) } - func testQueryListPosts() async throws { - _ = try await saveAsync(ParentPost4V2(title: "title")) - _ = try await saveAsync(ParentPost4V2(title: "title")) + func testSaveMultipleThenQueryPosts() async throws { + try await saveAsync(ParentPost4V2(title: "title")) + try await saveAsync(ParentPost4V2(title: "title")) - let comments = try await queryAsync(ParentPost4V2.self) + let comments = try await queryAsync(ParentPost4V2.self, eagerLoad: true) XCTAssertEqual(comments.count, 2) } - func testPostHasLazyLoadedComments() async throws { - let post = ParentPost4V2(title: "title") + // TODO: clean up this test + func testCommentWithPost_TranslateToStorageValues() async throws { + let post = ParentPost4V2(id: "postId", title: "title") _ = try await saveAsync(post) - let comment = ChildComment4V2(content: "content", post: post) - _ = try await saveAsync(comment) + var comment = ChildComment4V2(content: "content", post: post) - guard let queriedPost = try await queryAsync(ParentPost4V2.self, - byIdentifier: post.id) else { - XCTFail("Failed to query saved post") - return - } - XCTAssertEqual(queriedPost.id, post.id) - guard let comments = queriedPost.comments else { - XCTFail("Failed to get comments from queried post") - return - } - - switch comments.listProvider.getState() { - case .notLoaded(let associatedId, let associatedField): - XCTAssertEqual(associatedId, post.id) - XCTAssertEqual(associatedField, "post") - case .loaded(let comments): - print("loaded comments \(comments)") - XCTFail("Should not be loaded") - } + // Model.sqlValues testing + let sqlValues = comment.sqlValues(for: ChildComment4V2.schema.columns, + modelSchema: ChildComment4V2.schema) + XCTAssertEqual(sqlValues[0] as? String, comment.id) + XCTAssertEqual(sqlValues[1] as? String, comment.content) + XCTAssertNil(sqlValues[2]) // createdAt + XCTAssertNil(sqlValues[3]) // updatedAt + XCTAssertEqual(sqlValues[4] as? String, post.id) + + // InsertStatement testing + let insertStatement = InsertStatement(model: comment, + modelSchema: ChildComment4V2.schema) + XCTAssertEqual(insertStatement.variables[0] as? String, comment.id) + XCTAssertEqual(insertStatement.variables[1] as? String, comment.content) + XCTAssertNil(insertStatement.variables[2]) // createdAt + XCTAssertNil(insertStatement.variables[3]) // updatedAt + XCTAssertEqual(insertStatement.variables[4] as? String, post.id) + _ = try connection.prepare(insertStatement.stringValue).run(insertStatement.variables) + + // UpdateStatement testing + comment.content = "updatedContent" + let updateStatement = UpdateStatement(model: comment, + modelSchema: ChildComment4V2.schema, + condition: nil) + _ = try connection.prepare(updateStatement.stringValue).run(updateStatement.variables) + + + // Select + let selectStatement = SelectStatement(from: ChildComment4V2.schema, + predicate: field("id").eq(comment.id), + sort: nil, + paginationInput: nil, + eagerLoad: true) + let rows = try connection.prepare(selectStatement.stringValue).run(selectStatement.variables) + print(rows) + let result: [ModelValues] = try rows.convertToModelValues(to: ChildComment4V2.self, + withSchema: ChildComment4V2.schema, + using: selectStatement) + print(result) + XCTAssertEqual(result.count, 1) + // asert content is "updatedContent" } - func testCommentHasEagerLoadedPost() async throws { + func testSaveCommentWithPostThenQueryCommentAndAccessPost() async throws { let post = ParentPost4V2(title: "title") _ = try await saveAsync(post) let comment = ChildComment4V2(content: "content", post: post) _ = try await saveAsync(comment) guard let queriedComment = try await queryAsync(ChildComment4V2.self, - byIdentifier: comment.id) else { + byIdentifier: comment.id, + eagerLoad: true) else { XCTFail("Failed to query saved comment") return } @@ -134,47 +163,40 @@ final class StorageEngineTestsPostComment4V2Tests: StorageEngineTestsBase { XCTAssertEqual(eagerLoadedPost.id, post.id) } - func testCommentHasEagerLoadedPost_InsertUpdateSelect() async throws { + func testSaveCommentWithPostThenQueryPostAndAccessComments() async throws { let post = ParentPost4V2(title: "title") _ = try await saveAsync(post) - var comment = ChildComment4V2(content: "content", post: post) - - // Insert - let insertStatement = InsertStatement(model: comment, modelSchema: ChildComment4V2.schema) - print(insertStatement.stringValue) - print(insertStatement.variables) - _ = try connection.prepare(insertStatement.stringValue).run(insertStatement.variables) - - // Update - comment.content = "updatedContent" - let updateStatement = UpdateStatement(model: comment, - modelSchema: ChildComment4V2.schema, - condition: nil) - _ = try connection.prepare(updateStatement.stringValue).run(updateStatement.variables) - + let comment = ChildComment4V2(content: "content", post: post) + _ = try await saveAsync(comment) - // Select - let selectStatement = SelectStatement(from: ChildComment4V2.schema, - predicate: field("id").eq(comment.id), - sort: nil, - paginationInput: nil, - eagerLoad: true) - let rows = try connection.prepare(selectStatement.stringValue).run(selectStatement.variables) - print(rows) - let result: [ModelValues] = try rows.convertToModelValues(to: ChildComment4V2.self, - withSchema: ChildComment4V2.schema, - using: selectStatement) - print(result) - XCTAssertEqual(result.count, 1) - // asert content is "updatedContent" + guard let queriedPost = try await queryAsync(ParentPost4V2.self, + byIdentifier: post.id, + eagerLoad: true) else { + XCTFail("Failed to query saved post") + return + } + XCTAssertEqual(queriedPost.id, post.id) + guard let comments = queriedPost.comments else { + XCTFail("Failed to get comments from queried post") + return + } + + switch comments.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, post.id) + XCTAssertEqual(associatedField, "post") + case .loaded(let comments): + print("loaded comments \(comments)") + XCTFail("Should not be loaded") + } } - func testComentHasEagerLoadedPost() async throws { + func testSaveMultipleCommentWithPostThenQueryCommentsAndAccessPost() async throws { let post1 = try await saveAsync(ParentPost4V2(id: "postId1", title: "title1")) _ = try await saveAsync(ChildComment4V2(id: "id1", content: "content", post: post1)) let post2 = try await saveAsync(ParentPost4V2(id: "postId2", title: "title2")) _ = try await saveAsync(ChildComment4V2(id: "id2", content: "content", post: post2)) - let comments = try await queryAsync(ChildComment4V2.self) + let comments = try await queryAsync(ChildComment4V2.self, eagerLoad: true) XCTAssertEqual(comments.count, 2) guard let comment1 = comments.first(where: { $0.id == "id1" }) else { XCTFail("Couldn't find comment with `id1`") @@ -198,131 +220,38 @@ final class StorageEngineTestsPostComment4V2Tests: StorageEngineTestsBase { XCTAssertEqual(post2.title, "title2") } -} - -// MARK: - Models - -public struct ParentPost4V2: Model { - public let id: String - public var title: String - public var comments: List? - public var createdAt: Temporal.DateTime? - public var updatedAt: Temporal.DateTime? - - public init(id: String = UUID().uuidString, - title: String, - comments: List? = []) { - self.init(id: id, - title: title, - comments: comments, - createdAt: nil, - updatedAt: nil) - } - internal init(id: String = UUID().uuidString, - title: String, - comments: List? = [], - createdAt: Temporal.DateTime? = nil, - updatedAt: Temporal.DateTime? = nil) { - self.id = id - self.title = title - self.comments = comments - self.createdAt = createdAt - self.updatedAt = updatedAt - } -} -extension ParentPost4V2 { - // MARK: - CodingKeys - public enum CodingKeys: String, ModelKey { - case id - case title - case comments - case createdAt - case updatedAt - } - - public static let keys = CodingKeys.self - // MARK: - ModelSchema - - public static let schema = defineSchema { model in - let post4V2 = Post4V2.keys - - model.authRules = [ - rule(allow: .public, operations: [.create, .update, .delete, .read]) - ] - - model.pluralName = "Post4V2s" - - model.fields( - .id(), - .field(post4V2.title, is: .required, ofType: .string), - .hasMany(post4V2.comments, is: .optional, ofType: ChildComment4V2.self, associatedWith: ChildComment4V2.keys.post), - .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), - .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) - ) - } -} - -public struct ChildComment4V2: Model { - public let id: String - public var content: String - public var post: ParentPost4V2? - public var createdAt: Temporal.DateTime? - public var updatedAt: Temporal.DateTime? - - public init(id: String = UUID().uuidString, - content: String, - post: ParentPost4V2? = nil) { - self.init(id: id, - content: content, - post: post, - createdAt: nil, - updatedAt: nil) - } - internal init(id: String = UUID().uuidString, - content: String, - post: ParentPost4V2? = nil, - createdAt: Temporal.DateTime? = nil, - updatedAt: Temporal.DateTime? = nil) { - self.id = id - self.content = content - self.post = post - self.createdAt = createdAt - self.updatedAt = updatedAt - } -} - -extension ChildComment4V2 { - // MARK: - CodingKeys - public enum CodingKeys: String, ModelKey { - case id - case content - case post - case createdAt - case updatedAt - } - - public static let keys = CodingKeys.self - // MARK: - ModelSchema - - public static let schema = defineSchema { model in - let comment4V2 = Comment4V2.keys - - model.authRules = [ - rule(allow: .public, operations: [.create, .update, .delete, .read]) - ] - - model.pluralName = "Comment4V2s" - - model.attributes( - .index(fields: ["postID", "content"], name: "byPost4") - ) - - model.fields( - .id(), - .field(comment4V2.content, is: .required, ofType: .string), - .belongsTo(comment4V2.post, is: .optional, ofType: ParentPost4V2.self, targetName: "postID"), - .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), - .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) - ) + func testSaveMultipleCommentWithPostThenQueryPostAndAccessComments() async throws { + let post1 = try await saveAsync(ParentPost4V2(id: "postId1", title: "title1")) + _ = try await saveAsync(ChildComment4V2(id: "id1", content: "content", post: post1)) + let post2 = try await saveAsync(ParentPost4V2(id: "postId2", title: "title2")) + _ = try await saveAsync(ChildComment4V2(id: "id2", content: "content", post: post2)) + let posts = try await queryAsync(ParentPost4V2.self, eagerLoad: true) + XCTAssertEqual(posts.count, 2) + guard let postId1 = posts.first(where: { $0.id == "postId1" }) else { + XCTFail("Couldn't find comment with `id1`") + return + } + guard let postId2 = posts.first(where: { $0.id == "postId2" }) else { + XCTFail("Couldn't find comment with `id2`") + return + } + switch postId1.comments?.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, postId1.id) + XCTAssertEqual(associatedField, "post") + case .loaded: + XCTFail("Should not be loaded") + default: + XCTFail("missing comments") + } + switch postId2.comments?.listProvider.getState() { + case .notLoaded(let associatedId, let associatedField): + XCTAssertEqual(associatedId, postId2.id) + XCTAssertEqual(associatedField, "post") + case .loaded: + XCTFail("Should not be loaded") + default: + XCTFail("missing comments") + } } } diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/Support/MockSQLiteStorageEngineAdapter.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/Support/MockSQLiteStorageEngineAdapter.swift index 2e3d4b438a..5d32a1681c 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/Support/MockSQLiteStorageEngineAdapter.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/Support/MockSQLiteStorageEngineAdapter.swift @@ -164,6 +164,7 @@ class MockSQLiteStorageEngineAdapter: StorageEngineAdapter { func save(_ model: M, condition: QueryPredicate?, + eagerLoad: Bool, completion: @escaping DataStoreCallback) { if let responder = responders[.saveModelCompletion] as? SaveModelCompletionResponder { responder.callback((model, completion)) @@ -178,6 +179,7 @@ class MockSQLiteStorageEngineAdapter: StorageEngineAdapter { func save(_ model: M, modelSchema: ModelSchema, condition where: QueryPredicate?, + eagerLoad: Bool, completion: @escaping DataStoreCallback) { if let responder = responders[.saveModelCompletion] as? SaveModelCompletionResponder { responder.callback((model, completion)) @@ -321,13 +323,17 @@ class MockStorageEngineBehavior: StorageEngineBehavior { func applyModelMigrations(modelSchemas: [ModelSchema]) throws { } - func save(_ model: M, condition: QueryPredicate?, completion: @escaping DataStoreCallback) { + func save(_ model: M, + condition: QueryPredicate?, + eagerLoad: Bool, + completion: @escaping DataStoreCallback) { XCTFail("Not expected to execute") } func save(_ model: M, modelSchema: ModelSchema, condition where: QueryPredicate?, + eagerLoad: Bool, completion: @escaping DataStoreCallback) { XCTFail("Not expected to execute") } diff --git a/AmplifyTestCommon/SharedTestCases/SharedTestCasesPostComment4V2.swift b/AmplifyTestCommon/SharedTestCases/SharedTestCasesPostComment4V2.swift new file mode 100644 index 0000000000..18e22a62ef --- /dev/null +++ b/AmplifyTestCommon/SharedTestCases/SharedTestCasesPostComment4V2.swift @@ -0,0 +1,320 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation + +/* + type Post4V2 @model @auth(rules: [{allow: public}]) { + id: ID! + title: String! + comments: [Comment4V2] @hasMany(indexName: "byPost4", fields: ["id"]) + } + + type Comment4V2 @model @auth(rules: [{allow: public}]) { + id: ID! + postID: ID! @index(name: "byPost4", sortKeyFields: ["content"]) + content: String! + post: Post4V2 @belongsTo(fields: ["postID"]) + } + */ + +protocol SharedTestCasesPostComment4V2 { + + func testSaveCommentThenQueryComment() async throws + + func testSavePostThenQueryPost() async throws + + func testSaveMultipleThenQueryComments() async throws + + func testSaveMultipleThenQueryPosts() async throws + + func testSaveCommentWithPostThenQueryCommentAndAccessPost() async throws + + func testSaveCommentWithPostThenQueryPostAndAccessComments() async throws + + func testSaveMultipleCommentWithPostThenQueryCommentsAndAccessPost() async throws + + func testSaveMultipleCommentWithPostThenQueryPostAndAccessComments() async throws +} + +// MARK: - Models with LazyModel + +public struct LazyParentPost4V2: Model { + public let id: String + public var title: String + public var comments: List? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + title: String, + comments: List? = []) { + self.init(id: id, + title: title, + comments: comments, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + title: String, + comments: List? = [], + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.title = title + self.comments = comments + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} +extension LazyParentPost4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case title + case comments + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let post4V2 = Post4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Post4V2s" + + model.fields( + .id(), + .field(post4V2.title, is: .required, ofType: .string), + .hasMany(post4V2.comments, is: .optional, ofType: LazyChildComment4V2.self, associatedWith: LazyChildComment4V2.keys.post), + .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +public struct LazyChildComment4V2: Model { + public let id: String + public var content: String + internal var _post: LazyModel + public var post: LazyParentPost4V2? { + get async throws { + try await _post.get() + } + } + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + content: String, + post: LazyParentPost4V2? = nil) { + self.init(id: id, + content: content, + post: post, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + content: String, + post: LazyParentPost4V2? = nil, + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.content = content + self._post = LazyModel(element: post) + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + public mutating func setPost(_ post: LazyParentPost4V2) { + self._post = LazyModel(element: post) + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + content = try values.decode(String.self, forKey: .content) + _post = try values.decode(LazyModel.self, forKey: .post) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(content, forKey: .content) + try container.encode(_post, forKey: .post) + } +} + +extension LazyChildComment4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case content + case post + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let comment4V2 = Comment4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Comment4V2s" + + model.attributes( + .index(fields: ["postID", "content"], name: "byPost4") + ) + + model.fields( + .id(), + .field(comment4V2.content, is: .required, ofType: .string), + .belongsTo(comment4V2.post, is: .optional, ofType: LazyParentPost4V2.self, targetName: "postID"), + .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +// MARK: - Models without LazyModel + +public struct ParentPost4V2: Model { + public let id: String + public var title: String + public var comments: List? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + title: String, + comments: List? = []) { + self.init(id: id, + title: title, + comments: comments, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + title: String, + comments: List? = [], + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.title = title + self.comments = comments + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} +extension ParentPost4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case title + case comments + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let post4V2 = Post4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Post4V2s" + + model.fields( + .id(), + .field(post4V2.title, is: .required, ofType: .string), + .hasMany(post4V2.comments, is: .optional, ofType: ChildComment4V2.self, associatedWith: ChildComment4V2.keys.post), + .field(post4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(post4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +public struct ChildComment4V2: Model { + public let id: String + public var content: String + public var post: ParentPost4V2? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + content: String, + post: ParentPost4V2? = nil) { + self.init(id: id, + content: content, + post: post, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + content: String, + post: ParentPost4V2? = nil, + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.content = content + self.post = post + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +extension ChildComment4V2 { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case content + case post + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let comment4V2 = Comment4V2.keys + + model.authRules = [ + rule(allow: .public, operations: [.create, .update, .delete, .read]) + ] + + model.pluralName = "Comment4V2s" + + model.attributes( + .index(fields: ["postID", "content"], name: "byPost4") + ) + + model.fields( + .id(), + .field(comment4V2.content, is: .required, ofType: .string), + .belongsTo(comment4V2.post, is: .optional, ofType: ParentPost4V2.self, targetName: "postID"), + .field(comment4V2.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(comment4V2.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} From 3b04cc79304d310efd2b91eb6a14616606aa95b5 Mon Sep 17 00:00:00 2001 From: Michael Law <1365977+lawmicha@users.noreply.github.com> Date: Mon, 17 Oct 2022 05:20:02 -0400 Subject: [PATCH 4/8] update API ModelIdDecorator --- .../Core/AppSyncListProvider.swift | 28 +++++++++++++------ .../Model/Decorator/ModelIdDecorator.swift | 4 +++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift index 2c53b4daf1..843367b2e0 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListProvider.swift @@ -45,14 +45,9 @@ public class AppSyncModelProvider: ModelProvider { switch loadedState { case .notLoaded(let identifiers): - // TODO: account for more than one identifier - guard let identifier = identifiers.first else { - throw CoreError.operation("CPK not yet implemented", "", nil) - } - let request = GraphQLRequest.getQuery(responseType: ModelType.self, - modelSchema: ModelType.schema, - identifier: identifier.value, - apiName: apiName) + let request = AppSyncModelProvider.getRequest(ModelType.self, + byIdentifiers: identifiers, + apiName: apiName) do { let graphQLResponse = try await Amplify.API.query(request: request) switch graphQLResponse { @@ -86,6 +81,23 @@ public class AppSyncModelProvider: ModelProvider { return .loaded(model) } } + + static func getRequest(_ modelType: M.Type, + byIdentifiers identifiers: [String: String], + apiName: String?) -> GraphQLRequest { + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, + operationType: .query) + documentBuilder.add(decorator: DirectiveNameDecorator(type: .get)) + documentBuilder.add(decorator: ModelIdDecorator(identifiers: identifiers)) + + let document = documentBuilder.build() + + return GraphQLRequest(apiName: apiName, + document: document.stringValue, + variables: document.variables, + responseType: M?.self, + decodePath: document.name) + } } public class AppSyncListProvider: ModelListProvider { diff --git a/AmplifyPlugins/Core/AWSPluginsCore/Model/Decorator/ModelIdDecorator.swift b/AmplifyPlugins/Core/AWSPluginsCore/Model/Decorator/ModelIdDecorator.swift index 9ee277eb08..847be9fdad 100644 --- a/AmplifyPlugins/Core/AWSPluginsCore/Model/Decorator/ModelIdDecorator.swift +++ b/AmplifyPlugins/Core/AWSPluginsCore/Model/Decorator/ModelIdDecorator.swift @@ -23,6 +23,10 @@ public struct ModelIdDecorator: ModelBasedGraphQLDocumentDecorator { return (name: fieldName, value: value) } } + + public init(identifiers: [String: String]) { + self.identifierFields = identifiers.map { (name: $0.0, value: $0.1) } + } @available(*, deprecated, message: "Use init(model:schema:)") public init(model: Model) { From 659005f01a31391284795021ce5de6422665a135 Mon Sep 17 00:00:00 2001 From: Michael Law <1365977+lawmicha@users.noreply.github.com> Date: Mon, 17 Oct 2022 07:23:18 -0400 Subject: [PATCH 5/8] update DataStore support CPK lazy load --- .../Core/AppSyncListDecoder.swift | 2 +- .../Core/AppSyncModelMetadata.swift | 1 - .../Core/DataStoreModelDecoder.swift | 34 ++++++++++++------- .../Core/DataStoreModelProvider.swift | 16 ++++++--- .../Storage/SQLite/Statement+Model.swift | 4 +-- ...geEngineTestsLazyPostComment4V2Tests.swift | 8 ++--- 6 files changed, 39 insertions(+), 26 deletions(-) diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift index 2c2666767a..c24e10b084 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncListDecoder.swift @@ -84,7 +84,7 @@ public struct AppSyncModelDecoder: ModelProviderDecoder { return AppSyncModelProvider(metadata: metadata) } let json = try JSONValue(from: decoder) - let message = "AppSyncListProvider could not be created from \(String(describing: json))" + let message = "AppSyncModelProvider could not be created from \(String(describing: json))" Amplify.DataStore.log.error(message) assertionFailure(message) return nil diff --git a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift index 5f8e6269e0..b040a510b3 100644 --- a/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift +++ b/AmplifyPlugins/API/Sources/AWSAPIPlugin/Core/AppSyncModelMetadata.swift @@ -16,7 +16,6 @@ public struct AppSyncModelMetadata: Codable { } /// Metadata that contains partial information of a model -// TODO this should expand to more than just the identifier for composite keys. public struct AppSyncModelIdentifierMetadata: Codable { let identifiers: [String: String] let apiName: String? diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift index ca9352f055..d7cdbf8fe6 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift @@ -7,6 +7,7 @@ import Foundation import Amplify +import SQLite public struct DataStoreModelDecoder: ModelProviderDecoder { @@ -14,14 +15,15 @@ public struct DataStoreModelDecoder: ModelProviderDecoder { // to indicate run 1 then run 2 public static func shouldDecode(modelType: ModelType.Type, decoder: Decoder) -> Bool { - guard let json = try? JSONValue(from: decoder) else { - return false + if (try? DataStoreModelIdentifierMetadata(from: decoder)) != nil { + return true } - // TODO: This needs to be more strict once we have more than one decoder running - // without any sort of priority - // check if it has the single field - - return true + + if (try? ModelType(from: decoder)) != nil { + return true + } + + return false } public static func makeModelProvider(modelType: ModelType.Type, @@ -36,15 +38,21 @@ public struct DataStoreModelDecoder: ModelProviderDecoder { static func getDataStoreModelProvider(modelType: ModelType.Type, decoder: Decoder) throws -> DataStoreModelProvider? { - let json = try? JSONValue(from: decoder) - - // Attempt to decode to the entire model as a loaded model provider if let model = try? ModelType.init(from: decoder) { return DataStoreModelProvider(model: model) - } else if case .string(let identifier) = json { // A not loaded model provider - return DataStoreModelProvider(identifier: identifier) + } else if let metadata = try? DataStoreModelIdentifierMetadata.init(from: decoder) { + return DataStoreModelProvider(metadata: metadata) } - + + let json = try? JSONValue(from: decoder) + let message = "DataStoreModelProvider could not be created from \(String(describing: json))" + Amplify.DataStore.log.error(message) + assertionFailure(message) return nil } } + +/// Metadata that contains the primary keys and values of a model +public struct DataStoreModelIdentifierMetadata: Codable { + let identifier: String +} diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift index 34dd84d805..7769279f01 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Core/DataStoreModelProvider.swift @@ -18,12 +18,16 @@ public class DataStoreModelProvider: ModelProvider { var loadedState: LoadedState + convenience init(metadata: DataStoreModelIdentifierMetadata) { + + self.init(identifiers: [ModelType.schema.primaryKey.sqlName: metadata.identifier]) + } + init(model: ModelType?) { self.loadedState = .loaded(model: model) } - init(identifier: String) { - let identifiers: [String: String] = ["id": identifier] + init(identifiers: [String: String]) { self.loadedState = .notLoaded(identifiers: identifiers) } @@ -32,12 +36,14 @@ public class DataStoreModelProvider: ModelProvider { public func load() async throws -> ModelType? { switch loadedState { case .notLoaded(let identifiers): - // TODO: identifers should allow us to pass in just the `id` or the composite key ? - // or directly query against using the `@@primaryKey` ? guard let identifier = identifiers.first else { return nil } - let model = try await Amplify.DataStore.query(ModelType.self, byId: identifier.value) + let queryPredicate: QueryPredicate = field(identifier.key).eq(identifier.value) + let models = try await Amplify.DataStore.query(ModelType.self, where: queryPredicate) + guard let model = models.first else { + return nil + } self.loadedState = .loaded(model: model) return model case .loaded(let model): diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift index 957e42de32..60f7baf00e 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift @@ -177,10 +177,10 @@ extension Statement: StatementModelConvertible { if !eagerLoad { for foreignKeyValue in foreignKeyValues { let foreignColumnName = foreignKeyValue.0 - if let foreignModel = modelSchema.foreignKeys.first(where: { modelField in + if let foreignModelField = modelSchema.foreignKeys.first(where: { modelField in modelField.sqlName == foreignColumnName }) { - modelDictionary[foreignModel.name] = foreignKeyValue.1 + modelDictionary[foreignModelField.name] = ["identifier": foreignKeyValue.1] } } } diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift index 64ba434bec..75de48a691 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift @@ -190,10 +190,10 @@ final class StorageEngineTestsLazyPostComment4V2Tests: StorageEngineTestsBase, S withSchema: LazyChildComment4V2.schema, using: selectStatement2, eagerLoad: false) - let comments2 = try rows.convert(to: LazyChildComment4V2.self, - withSchema: LazyChildComment4V2.schema, - using: selectStatement2, - eagerLoad: false) + let comments2 = try rows2.convert(to: LazyChildComment4V2.self, + withSchema: LazyChildComment4V2.schema, + using: selectStatement2, + eagerLoad: false) XCTAssertEqual(comments.count, 1) guard let comment2 = comments2.first else { XCTFail("Should retrieve single comment") From 3066991020955becf72c931467095ce0b8c6e334 Mon Sep 17 00:00:00 2001 From: Michael Law <1365977+lawmicha@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:10:20 -0400 Subject: [PATCH 6/8] Add Configuration Loading Strategy --- ...ataStorePlugin+DataStoreBaseBehavior.swift | 4 ++-- .../AWSDataStorePlugin.swift | 7 +++++++ .../DataStoreConfiguration.swift | 19 ++++++++++++++++--- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift index 44a1ed7144..d84e6ace77 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift @@ -68,7 +68,7 @@ extension AWSDataStorePlugin: DataStoreBaseBehavior { storageEngine.save(model, modelSchema: modelSchema, condition: condition, - eagerLoad: true, + eagerLoad: isEagerLoad, completion: publishingCompletion) } @@ -218,7 +218,7 @@ extension AWSDataStorePlugin: DataStoreBaseBehavior { predicate: predicate, sort: sortInput, paginationInput: paginationInput, - eagerLoad: true, + eagerLoad: isEagerLoad, completion: completion) } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin.swift index 63c3d34a2f..1387e6f44c 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin.swift @@ -68,6 +68,13 @@ final public class AWSDataStorePlugin: DataStoreCategoryPlugin { iStorageEngineSink = newValue } } + + var isEagerLoad: Bool { + // TODO: We may want to remove the public config property all together and infer that data should be + // lazy loaded when the ModelRegistry contains models which uses the latest LazyModel + // or some sort of CLI version stored with the AmplifyModel class + dataStoreConfiguration.loadingStrategy == .eagerLoad ? true : false + } /// No-argument init that uses defaults for all providers public init(modelRegistration: AmplifyModelRegistration, diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/DataStoreConfiguration.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/DataStoreConfiguration.swift index a29b64cee0..f170451832 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/DataStoreConfiguration.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/DataStoreConfiguration.swift @@ -42,6 +42,11 @@ public enum DataStoreConflictHandlerResult { case retry(Model) } +public enum DataStoreLoadingStrategy { + case eagerLoad + case lazyLoad +} + /// The `DataStore` plugin configuration object. public struct DataStoreConfiguration { @@ -70,13 +75,17 @@ public struct DataStoreConfiguration { /// Authorization mode strategy public var authModeStrategyType: AuthModeStrategyType + /// Loading sttrategy + public var loadingStrategy: DataStoreLoadingStrategy + init(errorHandler: @escaping DataStoreErrorHandler, conflictHandler: @escaping DataStoreConflictHandler, syncInterval: TimeInterval, syncMaxRecords: UInt, syncPageSize: UInt, syncExpressions: [DataStoreSyncExpression], - authModeStrategy: AuthModeStrategyType = .default) { + authModeStrategy: AuthModeStrategyType = .default, + loadingStrategy: DataStoreLoadingStrategy = .eagerLoad) { self.errorHandler = errorHandler self.conflictHandler = conflictHandler self.syncInterval = syncInterval @@ -84,6 +93,7 @@ public struct DataStoreConfiguration { self.syncPageSize = syncPageSize self.syncExpressions = syncExpressions self.authModeStrategyType = authModeStrategy + self.loadingStrategy = loadingStrategy } } @@ -103,6 +113,7 @@ extension DataStoreConfiguration { /// - syncMaxRecords: the number of records to sync per execution /// - syncPageSize: the page size of each sync execution /// - authModeStrategy: authorization strategy (.default | multiauth) + /// - loadingStrategy: loading strategy (eagerLoad | lazyLoad) /// - Returns: an instance of `DataStoreConfiguration` with the passed parameters. public static func custom( errorHandler: @escaping DataStoreErrorHandler = { error in @@ -115,7 +126,8 @@ extension DataStoreConfiguration { syncMaxRecords: UInt = DataStoreConfiguration.defaultSyncMaxRecords, syncPageSize: UInt = DataStoreConfiguration.defaultSyncPageSize, syncExpressions: [DataStoreSyncExpression] = [], - authModeStrategy: AuthModeStrategyType = .default + authModeStrategy: AuthModeStrategyType = .default, + loadingStrategy: DataStoreLoadingStrategy = .eagerLoad ) -> DataStoreConfiguration { return DataStoreConfiguration(errorHandler: errorHandler, conflictHandler: conflictHandler, @@ -123,7 +135,8 @@ extension DataStoreConfiguration { syncMaxRecords: syncMaxRecords, syncPageSize: syncPageSize, syncExpressions: syncExpressions, - authModeStrategy: authModeStrategy) + authModeStrategy: authModeStrategy, + loadingStrategy: loadingStrategy) } /// The default configuration. diff --git a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/DataStoreHostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/DataStoreHostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a4a32c633f..30b41b3e6b 100644 --- a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/DataStoreHostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/DataStoreHostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/awslabs/aws-sdk-swift.git", "state" : { - "revision" : "eea9a9ac6aab16e99eec10169a56336f79ce2e37", - "version" : "0.2.6" + "revision" : "76d1b43bfc3eeafd9d09e5aa307e629882192a7d", + "version" : "0.2.7" } }, { From 9cdeb078572f13dbb2a8edcc93d2965ee84eabf3 Mon Sep 17 00:00:00 2001 From: Michael Law <1365977+lawmicha@users.noreply.github.com> Date: Mon, 17 Oct 2022 15:59:55 -0400 Subject: [PATCH 7/8] enable always eager load for save --- .../DataStore/Model/Lazy/LazyModel.swift | 12 +-- ...ataStorePlugin+DataStoreBaseBehavior.swift | 2 +- ...geEngineTestsLazyPostComment4V2Tests.swift | 6 +- .../DataStore/Model/LazyModelTests.swift | 77 +++++++++++++++++++ .../DataStore/Model/ListTests.swift | 2 +- 5 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 AmplifyTests/CategoryTests/DataStore/Model/LazyModelTests.swift diff --git a/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift b/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift index 44f893ef84..9bd2557b75 100644 --- a/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift +++ b/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift @@ -12,7 +12,7 @@ public class LazyModel: Codable, LazyModelMarker { /// Represents the data state of the `LazyModel`. enum LoadedState { - case notLoaded + case notLoaded(identifiers: [String: String]) case loaded(Element?) } var loadedState: LoadedState @@ -46,8 +46,8 @@ public class LazyModel: Codable, LazyModelMarker { switch self.modelProvider.getState() { case .loaded(let element): self.loadedState = .loaded(element) - case .notLoaded: - self.loadedState = .notLoaded + case .notLoaded(let identifiers): + self.loadedState = .notLoaded(identifiers: identifiers) } } @@ -76,9 +76,9 @@ public class LazyModel: Codable, LazyModelMarker { public func encode(to encoder: Encoder) throws { switch loadedState { - case .notLoaded: - break - // try Element.encode(to: encoder) + case .notLoaded(let identifiers): + var container = encoder.singleValueContainer() + try container.encode(identifiers) case .loaded(let element): try element.encode(to: encoder) } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift index d84e6ace77..bbd082753c 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift @@ -68,7 +68,7 @@ extension AWSDataStorePlugin: DataStoreBaseBehavior { storageEngine.save(model, modelSchema: modelSchema, condition: condition, - eagerLoad: isEagerLoad, + eagerLoad: true, completion: publishingCompletion) } diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift index 75de48a691..facd44412d 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Storage/StorageEngineTestsLazyPostComment4V2Tests.swift @@ -1,8 +1,8 @@ // -// StorageEngineTestsLazyPostComment4V2Tests.swift -// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. // -// Created by Law, Michael on 8/22/22. +// SPDX-License-Identifier: Apache-2.0 // diff --git a/AmplifyTests/CategoryTests/DataStore/Model/LazyModelTests.swift b/AmplifyTests/CategoryTests/DataStore/Model/LazyModelTests.swift new file mode 100644 index 0000000000..3ec2d4ebab --- /dev/null +++ b/AmplifyTests/CategoryTests/DataStore/Model/LazyModelTests.swift @@ -0,0 +1,77 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +@testable import Amplify +@testable import AmplifyTestCommon +@testable import AWSAPIPlugin + +final class LazyModelTests: XCTestCase { + + override func setUp() { + ModelRegistry.register(modelType: LazyParentPost4V2.self) + ModelRegistry.register(modelType: LazyChildComment4V2.self) + } + + class MockModelProvider: ModelProvider { + enum LoadedState { + case notLoaded(identifiers: [String: String]) + case loaded(model: ModelType?) + } + + var loadedState: LoadedState + + init(loadedState: LoadedState) { + self.loadedState = loadedState + } + + func load() async throws -> Element? { + return nil + } + + func getState() -> ModelProviderState { + switch loadedState { + case .notLoaded(let identifiers): + return .notLoaded(identifiers: identifiers) + case .loaded(let model): + return .loaded(model) + } + } + } + + func testEncodeDecodeLoaded() throws { + let post = LazyParentPost4V2(id: "postId", title: "t") + let comment = LazyChildComment4V2(id: "commentId", content: "c", post: post) + let json = try comment.toJSON() + XCTAssertEqual(json, "{\"id\":\"commentId\",\"content\":\"c\",\"post\":{\"id\":\"postId\",\"title\":\"t\",\"comments\":[]}}") + + guard let decodedComment = try ModelRegistry.decode(modelName: LazyChildComment4V2.modelName, from: json) as? LazyChildComment4V2 else { + XCTFail("Could not decode to comment from json") + return + } + switch decodedComment._post.loadedState { + case .notLoaded: + XCTFail("Should be loaded") + case .loaded(let element): + guard let loadedPost = element else { + XCTFail("Missing post") + return + } + XCTAssertEqual(loadedPost.id, post.id) + XCTAssertEqual(loadedPost.title, post.title) + } + } + + func testEncodeNotLoaded() async throws { + var comment = LazyChildComment4V2(id: "commentId", content: "content") + let modelProvider = MockModelProvider(loadedState: .notLoaded(identifiers: ["id": "postId"])).eraseToAnyModelProvider() + + comment._post = LazyModel(modelProvider: modelProvider) + let json = try comment.toJSON() + XCTAssertEqual(json, "{\"id\":\"commentId\",\"content\":\"content\",\"post\":{\"id\":\"postId\"}}") + } +} diff --git a/AmplifyTests/CategoryTests/DataStore/Model/ListTests.swift b/AmplifyTests/CategoryTests/DataStore/Model/ListTests.swift index 3299ba128e..27ba2429f2 100644 --- a/AmplifyTests/CategoryTests/DataStore/Model/ListTests.swift +++ b/AmplifyTests/CategoryTests/DataStore/Model/ListTests.swift @@ -66,7 +66,7 @@ class ListTests: XCTestCase { } public func getState() -> ModelListProviderState { - state ?? .notLoaded + state ?? .notLoaded(associatedId: "", associatedField: "") } public func load(completion: (Result<[Element], CoreError>) -> Void) { From 0475694ca6b37053ce820f08d07e547458abb1be Mon Sep 17 00:00:00 2001 From: Michael Law <1365977+lawmicha@users.noreply.github.com> Date: Fri, 21 Oct 2022 11:00:11 -0400 Subject: [PATCH 8/8] implement require() on LazyModel --- .../DataStore/Model/Lazy/LazyModel.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift b/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift index 9bd2557b75..726dd0e4df 100644 --- a/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift +++ b/Amplify/Categories/DataStore/Model/Lazy/LazyModel.swift @@ -41,6 +41,7 @@ public class LazyModel: Codable, LazyModelMarker { } } } + public init(modelProvider: AnyModelProvider) { self.modelProvider = modelProvider switch self.modelProvider.getState() { @@ -96,4 +97,20 @@ public class LazyModel: Codable, LazyModelMarker { return element } } + + public func require() async throws -> Element { + switch loadedState { + case .notLoaded: + guard let element = try await modelProvider.load() else { + throw CoreError.operation("Expected required element not found", "", nil) + } + self.loadedState = .loaded(element) + return element + case .loaded(let element): + guard let element = element else { + throw CoreError.operation("Expected required element not found", "", nil) + } + return element + } + } }