From 35a5d430b096424c3e9e4fa273db2842d0163920 Mon Sep 17 00:00:00 2001 From: Anthony Miller Date: Thu, 9 Sep 2021 13:06:50 -0700 Subject: [PATCH] Refactor InputValue evaluation to accept InputValue variables --- Sources/Apollo/GraphQLExecutor.swift | 286 ++------------------- Sources/Apollo/GraphQLResponse.swift | 9 +- Sources/Apollo/InputValue+Evaluation.swift | 46 ++-- Sources/ApolloAPI/InputValue.swift | 3 + 4 files changed, 43 insertions(+), 301 deletions(-) diff --git a/Sources/Apollo/GraphQLExecutor.swift b/Sources/Apollo/GraphQLExecutor.swift index b2478519ac..ad11721f31 100644 --- a/Sources/Apollo/GraphQLExecutor.swift +++ b/Sources/Apollo/GraphQLExecutor.swift @@ -5,7 +5,8 @@ import ApolloUtils #endif /// A field resolver is responsible for resolving a value for a field. -typealias GraphQLFieldResolver = (_ object: JSONObject, _ info: GraphQLResolveInfo) -> JSONValue? +typealias GraphQLFieldResolver = (_ object: JSONObject, _ info: FieldExecutionInfo) -> JSONValue? + /// A reference resolver is responsible for resolving an object based on its key. These references are /// used in normalized records, and data for these objects has to be loaded from the cache for execution to continue. /// Because data may be loaded from a database, these loads are batched for performance reasons. @@ -13,33 +14,13 @@ typealias GraphQLFieldResolver = (_ object: JSONObject, _ info: GraphQLResolveIn /// will defer loading the next batch of records from the cache until they are needed. typealias ReferenceResolver = (CacheReference) -> PossiblyDeferred -struct GraphQLResolveInfo { - let variables: GraphQLMap? - - var responsePath: ResponsePath = [] - var responseKeyForField: String = "" - - var cachePath: ResponsePath = [] - var cacheKeyForField: String = "" - - var fields: [GraphQLField] = [] - - init(rootKey: CacheKey?, variables: GraphQLMap?) { - self.variables = variables - - if let rootKey = rootKey { - cachePath = [rootKey] - } - } -} - struct ObjectExecutionInfo { - let variables: [String: JSONEncodable]? + let variables: [String: InputValue]? private(set) var responsePath: ResponsePath = [] private(set) var cachePath: ResponsePath = [] - init( - variables: [String: JSONEncodable]?, + fileprivate init( + variables: [String: InputValue]?, responsePath: ResponsePath, cachePath: ResponsePath ) { @@ -48,14 +29,14 @@ struct ObjectExecutionInfo { self.cachePath = cachePath } - init(variables: [String: JSONEncodable]?, rootCacheKey: CacheKey? = nil) { + fileprivate init(variables: [String: InputValue]?, rootCacheKey: CacheKey? = nil) { self.variables = variables if let key = rootCacheKey { cachePath = [key] } } - mutating func resetCachePath(toCacheKey key: String) { + fileprivate mutating func resetCachePath(toCacheKey key: String) { cachePath = [key] } } @@ -154,7 +135,12 @@ public struct GraphQLResultError: Error, LocalizedError { /// /// An executor is used both to parse a response received from the server, and to read from the normalized cache. It can also be configured with a accumulator that receives events during execution, and these execution events are used by `GraphQLResultNormalizer` to normalize a response into a flat set of records and by `GraphQLDependencyTracker` keep track of dependent keys. /// -/// The methods in this class closely follow the [execution algorithm described in the GraphQL specification](https://facebook.github.io/graphql/#sec-Execution), but an important difference is that execution returns a value for every selection in a selection set, not the merged fields. This means we get a separate result for every fragment, even though all fields that share a response key are still executed at the same time for efficiency. +/// The methods in this class closely follow the +/// [execution algorithm described in the GraphQL specification] +/// (http://spec.graphql.org/draft/#sec-Execution) +/// with an important difference: execution returns a value for every selection in a selection set, +/// not the merged fields. This means we get a separate result for every fragment, even though all +/// fields that share a response key are still executed at the same time for efficiency. /// /// So given the following query: /// @@ -198,7 +184,8 @@ public struct GraphQLResultError: Error, LocalizedError { /// - `FriendsAppearsIn` /// - `[FriendsAppearsIn.Friend]` /// -/// These values then get passed into a generated `GraphQLMappable` initializer, and this is how type safe results get built up. +/// These values then get passed into a generated `GraphQLMappable` initializer, and this is how +/// type safe results get built up. /// final class GraphQLExecutor { private let fieldResolver: GraphQLFieldResolver @@ -236,245 +223,11 @@ final class GraphQLExecutor { // MARK: - Execution - // TODO: Delete - func execute(selections: [GraphQLSelection], - on object: JSONObject, - withKey key: CacheKey? = nil, - variables: GraphQLMap? = nil, - accumulator: Accumulator) throws -> Accumulator.FinalResult { - let info = GraphQLResolveInfo(rootKey: key, variables: variables) - - let rootValue = execute(selections: selections, - on: object, - info: info, - accumulator: accumulator) - - return try accumulator.finish(rootValue: try rootValue.get()) - } - - // TODO: Delete - private func execute(selections: [GraphQLSelection], - on object: JSONObject, - info: GraphQLResolveInfo, - accumulator: Accumulator) -> PossiblyDeferred { - var groupedFields = GroupedSequence() - - do { - try collectFields(selections: selections, - forRuntimeType: runtimeType(of: object), - into: &groupedFields, - info: info) - } catch { - return .immediate(.failure(error)) - } - - var fieldEntries: [PossiblyDeferred] = [] - fieldEntries.reserveCapacity(groupedFields.keys.count) - - for (_, fields) in groupedFields { - let fieldEntry = execute(fields: fields, - on: object, - info: info, - accumulator: accumulator) - fieldEntries.append(fieldEntry) - } - - return lazilyEvaluateAll(fieldEntries).map { - try accumulator.accept(fieldEntries: $0, info: info) - } - } - - /// Before execution, the selection set is converted to a grouped field set. Each entry in the grouped field set is a list of fields that share a response key. This ensures all fields with the same response key (alias or field name) included via referenced fragments are executed at the same time. - private func collectFields(selections: [GraphQLSelection], - forRuntimeType runtimeType: String?, - into groupedFields: inout GroupedSequence, - info: GraphQLResolveInfo) throws { - for selection in selections { - switch selection { - case let field as GraphQLField: - _ = groupedFields.append(value: field, forKey: field.responseKey) - case let booleanCondition as GraphQLBooleanCondition: - guard let value = info.variables?[booleanCondition.variableName] else { - throw GraphQLError("Variable \(booleanCondition.variableName) was not provided.") - } - if value as? Bool == !booleanCondition.inverted { - try collectFields(selections: booleanCondition.selections, - forRuntimeType: runtimeType, - into: &groupedFields, - info: info) - } -// case let fragmentSpread as GraphQLFragmentSpread: -// let fragment = fragmentSpread.fragment - -// if let runtimeType = runtimeType, fragment.possibleTypes.contains(runtimeType) { -// try collectFields(selections: fragment.selections, -// forRuntimeType: runtimeType, -// into: &groupedFields, -// info: info) -// } - case let typeCase as GraphQLTypeCase: - let selections: [GraphQLSelection] - if let runtimeType = runtimeType { - selections = typeCase.variants[runtimeType] ?? typeCase.default - } else { - selections = typeCase.default - } - try collectFields(selections: selections, - forRuntimeType: runtimeType, - into: &groupedFields, - info: info) - default: - preconditionFailure() - } - } - } - - /// Each field requested in the grouped field set that is defined on the selected objectType will result in an entry in the response map. Field execution first coerces any provided argument values, then resolves a value for the field, and finally completes that value either by recursively executing another selection set or coercing a scalar value. - private func execute(fields: [GraphQLField], - on object: JSONObject, - info: GraphQLResolveInfo, - accumulator: Accumulator) -> PossiblyDeferred { - // GraphQL validation makes sure all fields sharing the same response key have the same arguments and are of the same type, so we only need to resolve one field. - let firstField = fields[0] - - var info = info - - let responseKey = firstField.responseKey - info.responseKeyForField = responseKey - info.responsePath.append(responseKey) - - if shouldComputeCachePath { - do { - let cacheKey = try firstField.cacheKey(with: info.variables) - info.cacheKeyForField = cacheKey - info.cachePath.append(cacheKey) - } catch { - return .immediate(.failure(error)) - } - } - - // We still need all fields to complete the value, because they may have different selection sets. - info.fields = fields - - return PossiblyDeferred { - guard let value = fieldResolver(object, info) else { - throw JSONDecodingError.missingValue - } - return value - }.flatMap { - return self.complete(value: $0, - ofType: firstField.type, - info: info, - accumulator: accumulator) - }.map { - try accumulator.accept(fieldEntry: $0, info: info) - }.mapError { error in - if !(error is GraphQLResultError) { - return GraphQLResultError(path: info.responsePath, underlying: error) - } else { - return error - } - } - } - - /// After resolving the value for a field, it is completed by ensuring it adheres to the expected return type. If the return type is another Object type, then the field execution process continues recursively. - private func complete(value: JSONValue, - ofType returnType: GraphQLOutputType, - info: GraphQLResolveInfo, - accumulator: Accumulator) -> PossiblyDeferred { - if case .nonNull(let innerType) = returnType { - if value is NSNull { - return .immediate(.failure(JSONDecodingError.nullValue)) - } - - return complete(value: value, - ofType: innerType, - info: info, - accumulator: accumulator) - } - - if value is NSNull { - return PossiblyDeferred { try accumulator.acceptNullValue(info: info) } - } - - switch returnType { - case .scalar: - return PossiblyDeferred { try accumulator.accept(scalar: value, info: info) } - case .list(let innerType): - guard let array = value as? [JSONValue] else { - return .immediate(.failure(JSONDecodingError.wrongType)) - } - - let completedArray = array.enumerated().map { index, element -> PossiblyDeferred in - var info = info - - let indexSegment = String(index) - info.responsePath.append(indexSegment) - - if shouldComputeCachePath { - info.cachePath.append(indexSegment) - } - - return self.complete(value: element, - ofType: innerType, - info: info, - accumulator: accumulator) - } - - return lazilyEvaluateAll(completedArray).map { - try accumulator.accept(list: $0, info: info) - } - case .object: - if let reference = value as? CacheReference, let resolveReference = resolveReference { - return resolveReference(reference).flatMap { - self.complete(value: $0, - ofType: returnType, - info: info, - accumulator: accumulator) - } - } - - guard let object = value as? JSONObject else { - return .immediate(.failure(JSONDecodingError.wrongType)) - } - - // The merged selection set is a list of fields from all sub‐selection sets of the original fields. - let selections = mergeSelectionSets(for: info.fields) - - var info = info - if shouldComputeCachePath, let cacheKeyForObject = self.cacheKey(for: object) { - info.cachePath = [cacheKeyForObject] - } - - // We execute the merged selection set on the object to complete the value. This is the recursive step in the GraphQL execution model. - return self.execute(selections: selections, - on: object, - info: info, - accumulator: accumulator).map { $0 as! Accumulator.PartialResult } - default: - preconditionFailure() - } - } - - /// When fields are selected multiple times, their selection sets are merged together when completing the value in order to continue execution of the sub‐selection sets. - private func mergeSelectionSets(for fields: [GraphQLField]) -> [GraphQLSelection] { - var selections: [GraphQLSelection] = [] - for field in fields { - if case let .object(fieldSelections) = field.type.namedType { - selections.append(contentsOf: fieldSelections) - } - } - return selections - } - - - // MARK: - New - func execute( selectionSet: AnySelectionSet.Type, onData data: JSONObject, withKey key: CacheKey? = nil, - variables: [String: JSONEncodable]? = nil, + variables: [String: InputValue]? = nil, accumulator: Accumulator ) throws -> Accumulator.FinalResult { return try execute(selections: selectionSet.selections, @@ -488,7 +241,7 @@ final class GraphQLExecutor { selections: [Selection], // TODO: Pass in AnySelectionSet? on object: JSONObject, withKey key: CacheKey? = nil, - variables: [String: JSONEncodable]? = nil, + variables: [String: InputValue]? = nil, accumulator: Accumulator ) throws -> Accumulator.FinalResult { let info = ObjectExecutionInfo(variables: variables, rootCacheKey: key) @@ -732,10 +485,7 @@ final class GraphQLExecutor { default: return .immediate(.failure(JSONDecodingError.wrongType)) - } - - default: - preconditionFailure() + } } } diff --git a/Sources/Apollo/GraphQLResponse.swift b/Sources/Apollo/GraphQLResponse.swift index 3907b75fc6..39cb49fd58 100644 --- a/Sources/Apollo/GraphQLResponse.swift +++ b/Sources/Apollo/GraphQLResponse.swift @@ -7,19 +7,14 @@ public final class GraphQLResponse { public let body: JSONObject - private var rootKey: String - private var variables: [String: JsonE]? + private let rootKey: String + private let variables: [String: InputValue]? public init(operation: Operation, body: JSONObject) where Operation.Data == Data { self.body = body rootKey = rootCacheKey(for: operation) variables = operation.variables } - - func setupOperation (_ operation: Operation) { - self.rootKey = rootCacheKey(for: operation) - self.variables = operation.variables - } /// Parses a response into a `GraphQLResult` and a `RecordSet`. /// The result can be sent to a completion block for a request. diff --git a/Sources/Apollo/InputValue+Evaluation.swift b/Sources/Apollo/InputValue+Evaluation.swift index 520229be7d..055175f3de 100644 --- a/Sources/Apollo/InputValue+Evaluation.swift +++ b/Sources/Apollo/InputValue+Evaluation.swift @@ -4,7 +4,7 @@ import ApolloAPI import Foundation extension Selection.Field { - func cacheKey(with variables: [String: JSONEncodable]?) throws -> String { + func cacheKey(with variables: [String: InputValue]?) throws -> String { if let argumentValues = try arguments?.evaluate(with: variables), argumentValues.apollo.isNotEmpty { @@ -29,34 +29,36 @@ extension Selection.Field { } extension Selection.Field.Arguments { - func evaluate(with variables: [String: JSONEncodable]?) throws -> JSONValue { - return try arguments.evaluate(with: variables) - } - func evaluate(with variables: [String: JSONEncodable]?) throws -> JSONObject { + func evaluate(with variables: [String: InputValue]?) throws -> JSONObject { + /// `Selection.Field.Arguments` can only ever hold arguments of `.object` type. Which will + /// always resolve to an `.object` value. So force casting to `JSONObject` is safe. return try arguments.evaluate(with: variables) as! JSONObject } } extension InputValue { - func evaluate(with variables: [String: JSONEncodable]?) throws -> JSONValue { + func evaluate(with variables: [String: InputValue]?) throws -> JSONValue { switch self { - case .scalar(let scalar as JSONEncodable): - return scalar.jsonValue - - case .scalar(let scalar): - throw GraphQLError("Scalar value \(scalar) is not JSONEncodable.") + case let .scalar(value): + return value - case .variable(let name): + case let .variable(name): guard let value = variables?[name] else { - throw GraphQLError("Variable \(name) was not provided.") + throw GraphQLError("Variable \"\(name)\" was not provided.") + } + + switch value { + case let .variable(nestedName) where name == nestedName: + throw GraphQLError("Variable \"\(name)\" is infinitely recursive.") + default: + return try value.evaluate(with: variables) } - return value.jsonValue - case .list(let array): + case let .list(array): return try evaluate(values: array, with: variables) - case .object(let dictionary): + case let .object(dictionary): return try evaluate(values: dictionary, with: variables) case .none: @@ -64,19 +66,11 @@ extension InputValue { } } - private func evaluate(values: [InputValue], with variables: [String: JSONEncodable]?) throws -> JSONValue { - return try evaluate(values: values, with: variables) as [JSONValue] - } - - private func evaluate(values: [InputValue], with variables: [String: JSONEncodable]?) throws -> [JSONValue] { + private func evaluate(values: [InputValue], with variables: [String: InputValue]?) throws -> [JSONValue] { try values.map { try $0.evaluate(with: variables) } } - private func evaluate(values: [String: InputValue], with variables: [String: JSONEncodable]?) throws -> JSONValue { - return try evaluate(values: values, with: variables) as JSONObject - } - - private func evaluate(values: [String: InputValue], with variables: [String: JSONEncodable]?) throws -> JSONObject { + private func evaluate(values: [String: InputValue], with variables: [String: InputValue]?) throws -> JSONObject { var jsonObject = JSONObject(minimumCapacity: values.count) for (key, value) in values { let evaluatedValue = try value.evaluate(with: variables) diff --git a/Sources/ApolloAPI/InputValue.swift b/Sources/ApolloAPI/InputValue.swift index 453384af4b..74e7ea560f 100644 --- a/Sources/ApolloAPI/InputValue.swift +++ b/Sources/ApolloAPI/InputValue.swift @@ -9,6 +9,9 @@ public indirect enum InputValue { case scalar(ScalarType) /// A variable input value to be evaluated using the operation's `variables` dictionary at runtime. + /// + /// `.variable` should only be used as the value for an argument in a `Selection.Field`. + /// A `.variable` value should not be included in an operation's `variables` dictionary. case variable(String) /// A GraphQL "List" input value.