Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor GraphQLOperation definition models #1964

Merged
merged 5 commits into from
Sep 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 137 additions & 14 deletions CodegenProposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,13 @@ enum SkinCovering {
}
```

# Proposal
# Core Concepts

In order to fulfill all of the stated goals of this project, the following approach is proposed for the structure of the Codegen:


# `SelectionSet` - A “View” of an Entity
## `SelectionSet` - A “View” of an Entity
---

We will refer to each individual object fetched in a GraphQL response as an “entity”. An entity defines a single type (object, interface, or union) that has fields on it that can be fetched.

Expand All @@ -231,15 +232,17 @@ Each animal in the list of `allAnimals` is a single entity. Each of those entiti

Each generated data object conforms to a `SelectionSet` protocol, which defines some universal behaviors. Type cases, fragments, and root types all conform to this protocol. For reference see [SelectionSet.swift](Sources/ApolloAPI/SelectionSet.swift).

## `SelectionSet` Data is Represented as Structs With Dictionary Storage
### `SelectionSet` Data is Represented as Structs With Dictionary Storage
---

The generated data objects are structs that have a single stored property. The stored property is to another struct named `ResponseDict`, which has a single stored constant property of type `[String: Any]`.

Often times the same data can be represented by different generated types. For example, when checking a type condition or accessing a fragment on an entity. By using structs with a single dictionary pointer, we are able to reference the same underlying data, while providing different accessors for fields at different scopes.

This allows us to store all the fetched data for an entity one time, rather than duplicating data in memory. The structs allow for hyper-performant conversions, as they are stack allocated at compile time and just increment a pointer to the single storage dictionary reference.

## Field Accessors
### Field Accessors
---

Accessors to the fields that a generated object has are implemented as computed properties that access the dictionary storage.

Expand Down Expand Up @@ -277,17 +280,33 @@ struct Animal: SelectionSet, HasFragments {

In this simple example, the `Animal` object has a nested `Height` object. Each conforms to `SelectionSet` and each has a single stored property let data: `ResponseDict`. The `ResponseDict` is a struct that wraps the dictionary storage, and provides custom subscript accessors for casting/transforming the underlying data to the correct types. For more information and implementation details, see: [ResponseDict.swift](Sources/ApolloAPI/ResponseDict.swift)

# GraphQL Execution
## GraphQL Execution
---

GraphQL execution is the process in which the Apollo iOS client converts raw data — either from a network response or the local cache — into a `SelectionSet`. The execution process determines which fields should be “selected”; maps the data for those fields; decodes raw data to the correct types for the fields; validates that all fields have valid data; and returns `SelectionSet` objects that are guaranteed to be valid.

A field that is “selected” is mapped from the raw data onto the `SelectionSet` to be accessed using a generated field accessor. If data exists in the cache or on a raw network response for a field, but the field is not “selected” the resulting `SelectionSet` will not include that data after execution.

Because `SelectionSet` field access uses unsafe force casting under the hood, it is necessary that a `SelectionSet` is only ever created via the execution process. A `SelectionSet` that is initialized manually cannot be guaranteed to contain all the expected data for its field accessors, and as such, could cause crashes at run time. `SelectionSet`s returned from GraphQL execution are guaranteed to be safe.

## Nullable Arguments - `GraphQLNullable`
---

By default, `GraphQLOperation` field variables; fields on `InputObject`s; and field arguments are nullable. For a nullable argument, the value can be provided as a value, a `null` value, or omitted entirely. In `GraphQL`, omitting an argument and passing a `null` value have semantically different meanings. While often, they may be identical, it is up to the implementation of the server to interpret these values. For example, a `null` value for an argument on a mutation may indicate that a field on the object should be set to `null`, while omitting the argument indicates that the field should retain it's current value -- or be set to a default value.

Because of the semantic difference between `null` and ommitted arguments, we have introduced `GraphQLNullable`. `GraphQLNullable` is a generic enum that acts very similarly to `Optional`, but it differentiates between a `nil` value (the `.none` case), and a `null` value (the `.null` case). Values are still wrapped using the `.some(value)` case as in `Optional`.

The previous Apollo versions used double optionals (`??`) to represent `null` vs ` nil`. This was unclear to most users and make reading and reasoning about your code difficult in many situations. `GraphQLNullable` makes your intentions clear and explicit when dealing with nullable input values.

For more information and implementation details, see: [GraphQLNullable.swift](Sources/ApolloAPI/GraphQLNullable.swift)

# Generated Objects

An overview of the format of all generated object types.

# Schema Type Generation

In addition to generating `SelectionSet`s for your GraphQL operations, types will be generated for each type (object, interface, or union) that is used in any operations across your entire application. These types will include all the fields that may be fetched by any operation used and can include other type metadata.
In addition to generating `SelectionSet`s for your `GraphQLOperation`s, types will be generated for each type (object, interface, or union) that is used in any operations across your entire application. These types will include all the fields that may be fetched by any operation used and can include other type metadata.

The schema types have a number of functions.

Expand Down Expand Up @@ -319,6 +338,114 @@ A `SchemaConfiguration` object will also be generated for your schema. This obje

For an example of generated schema metadata see [AnimalSchema.swift](Sources/ApolloAPI/AnimalSchema.swift).

# `InputObject` Generation

TODO

# `EnumType` Generation

TODO

# `GraphQLOperation` Generation

A `GraphQLOperation` is generated for each operation defined in your application. `GraphQLOperation`s can be queries (`GraphQLQuery`), mutations (`GraphQLMutation`), or subscriptions (`GraphQLSubscription`).

Each generated operation will conform to the `GraphQLOperation` protocol defined in [GraphQLOperation.swift](Sources/ApolloAPI/GraphQLOperation.swift).

**Simple Operation - Example:**

```swift
class AnimalQuery: GraphQLQuery {
let operationName: String = "AnimalQuery"
let document: DocumentType = .notPersisted(definition: .init(
"""
query AnimalQuery {
allAnimals {
species
}
}
""")

init() {}

struct Data: SelectionSet {
// ...
}
}
```

## Operation Arguments

For an operation that takes input arguments, the initializer will be generated with parameters for each argument. Arguments can be scalar types, `GraphQLEnum`s, or `InputObject`s. During execution, these arguments will be used as the operation's `variables`, which are then used as the values for arguments on `SelectionSet` fields matching the variables name.

**Operation With Scalar Argument - Example:**

```swift
class AnimalQuery: GraphQLQuery {
let operationName: String = "AnimalQuery"
let document: DocumentType = .notPersisted(definition: .init(
"""
query AnimalQuery($count: Int!) {
allAnimals {
predators(first: $count) {
species
}
}
}
""")

var count: Int

init(count: Int) {
self.count = count
}

var variables: Variables? { ["count": count] }

struct Data: SelectionSet {
// ...
struct Animal: SelectionSet {
static var selections: [Selection] {[
.field("predators", [Predator.self], arguments: ["first": .variable("count")])
]}
}
}
}
```
In this example, the value of the `count` property is passed into the `variables` for the variable with the key `"count"`. The `Selection` for the field `"predators"`, the argument `"first"` has a value of `.variable("count")`. During execution, the `predators` field will be evaluated with the argument from the operation's `"count"` variable.

### Nullable Operation Arguments

For nullable arguments, the code generator will wrap the argument value in a `GraphQLNullable`. The executor will evaluate the `GraphQLNullable` to format the operation variables correctly. See [GraphQLNullable](#nullable-arguments-graphqlnullable) for more information.

**Operation With Nullable Scalar Argument - Example:**

```swift
class AnimalQuery: GraphQLQuery {
let operationName: String = "AnimalQuery"
let document: DocumentType = .notPersisted(definition: .init(
"""
query AnimalQuery($count: Int) {
allAnimals {
predators(first: $count) {
species
}
}
}
""")

var count: GraphQLNullable<Int>

init(count: GraphQLNullable<Int>) {
self.count = count
}

var variables: Variables? { ["count": count] }

// ...
}
```

# `SelectionSet` Generation

## Metadata
Expand Down Expand Up @@ -602,7 +729,7 @@ query($count: Int) {
```

```swift
struct Query: GraphQLQuery {
class Query: GraphQLQuery {
var count: Int
init(count: Int) { ... }

Expand All @@ -629,7 +756,7 @@ query($skipSpecies: Boolean) {
```

```swift
struct Query: GraphQLQuery {
class Query: GraphQLQuery {
var skipSpecies: Bool
init(skipSpecies: Bool) { ... }

Expand Down Expand Up @@ -658,7 +785,7 @@ query($includeDetails: Boolean) {
```

```swift
struct Query: GraphQLQuery {
class Query: GraphQLQuery {
var includeDetails: Bool
init(includeDetails: Bool) { ... }

Expand Down Expand Up @@ -715,7 +842,7 @@ fragment AnimalDetails: RootSelectionSet, Fragment {
}
}

struct Query: GraphQLQuery {
class Query: GraphQLQuery {
struct Data: RootSelectionSet {
static var selections: [Selection] {[
.field("allAnimals", [Animal].self),
Expand Down Expand Up @@ -824,10 +951,6 @@ For this reason, only a `RootSelectionSet` can be executed by a `GraphQLExecutor

TODO

# `GraphQLEnum`

TODO

# Cache Key Resolution

TODO
Expand Down
8 changes: 4 additions & 4 deletions Sources/Apollo/HTTPRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ extension HTTPRequest: Equatable {

public static func == (lhs: HTTPRequest<Operation>, rhs: HTTPRequest<Operation>) -> Bool {
lhs.graphQLEndpoint == rhs.graphQLEndpoint
&& lhs.contextIdentifier == rhs.contextIdentifier
&& lhs.additionalHeaders == rhs.additionalHeaders
&& lhs.cachePolicy == rhs.cachePolicy
&& lhs.operation.queryDocument == rhs.operation.queryDocument
&& lhs.contextIdentifier == rhs.contextIdentifier
&& lhs.additionalHeaders == rhs.additionalHeaders
&& lhs.cachePolicy == rhs.cachePolicy
&& lhs.operation.definition?.queryDocument == rhs.operation.definition?.queryDocument
}
}

Expand Down
15 changes: 8 additions & 7 deletions Sources/Apollo/JSONRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,6 @@ open class JSONRequest<Operation: GraphQLOperation>: HTTPRequest<Operation> {
cachePolicy: cachePolicy)
}

open var sendOperationIdentifier: Bool {
self.operation.operationIdentifier != nil
}

open override func toURLRequest() throws -> URLRequest {
var request = try super.toURLRequest()

Expand All @@ -67,6 +63,12 @@ open class JSONRequest<Operation: GraphQLOperation>: HTTPRequest<Operation> {
switch operation.operationType {
case .query:
if isPersistedQueryRetry {
#warning("""
TODO: if using persistedOperationsOnly, we should throw an error. Not sure if that should be
here or somewhere else in the RequestChain? Probably earlier than this when we actually go to
start a retry.
Need to write unit tests for this new behavior.
""")
useGetMethod = self.useGETForPersistedQueryRetry
sendQueryDocument = true
autoPersistQueries = true
Expand All @@ -91,9 +93,8 @@ open class JSONRequest<Operation: GraphQLOperation>: HTTPRequest<Operation> {
}

let body = self.requestBodyCreator.requestBody(for: operation,
sendOperationIdentifiers: self.sendOperationIdentifier,
sendQueryDocument: sendQueryDocument,
autoPersistQuery: autoPersistQueries)
sendQueryDocument: sendQueryDocument,
autoPersistQuery: autoPersistQueries)

let httpMethod: GraphQLHTTPMethod = useGetMethod ? .GET : .POST
switch httpMethod {
Expand Down
20 changes: 6 additions & 14 deletions Sources/Apollo/RequestBodyCreator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,15 @@ import ApolloUtils
#endif

public protocol RequestBodyCreator {
/// Creates a `GraphQLMap` out of the passed-in operation
/// Creates a `JSONEncodableDictionary` out of the passed-in operation
///
/// - Parameters:
/// - operation: The operation to use
/// - sendOperationIdentifiers: Whether or not to send operation identifiers. Should default to `false`.
/// - sendQueryDocument: Whether or not to send the full query document. Should default to `true`.
/// - autoPersistQuery: Whether to use auto-persisted query information. Should default to `false`.
/// - Returns: The created `GraphQLMap`
/// - Returns: The created `JSONEncodableDictionary`
func requestBody<Operation: GraphQLOperation>(
for operation: Operation,
sendOperationIdentifiers: Bool,
sendQueryDocument: Bool,
autoPersistQuery: Bool
) -> JSONEncodableDictionary
Expand All @@ -27,7 +25,6 @@ extension RequestBodyCreator {

public func requestBody<Operation: GraphQLOperation>(
for operation: Operation,
sendOperationIdentifiers: Bool,
sendQueryDocument: Bool,
autoPersistQuery: Bool
) -> JSONEncodableDictionary {
Expand All @@ -39,16 +36,11 @@ extension RequestBodyCreator {
body["variables"] = variables.jsonEncodableObject
}

if sendOperationIdentifiers {
guard let operationIdentifier = operation.operationIdentifier else {
preconditionFailure("To send operation identifiers, Apollo types must be generated with operationIdentifiers")
}

body["id"] = operationIdentifier
}

if sendQueryDocument {
body["query"] = operation.queryDocument
guard let document = operation.definition?.queryDocument else {
preconditionFailure("To send query documents, Apollo types must be generated with `OperationDefinition`s.")
AnthonyMDev marked this conversation as resolved.
Show resolved Hide resolved
}
body["query"] = document
}

if autoPersistQuery {
Expand Down
3 changes: 0 additions & 3 deletions Sources/Apollo/UploadRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ open class UploadRequest<Operation: GraphQLOperation>: HTTPRequest<Operation> {
/// - Throws: Any error arising from creating the form data
/// - Returns: The created form data
open func requestMultipartFormData() throws -> MultipartFormData {
let shouldSendOperationID = (self.operation.operationIdentifier != nil)

let formData: MultipartFormData

if let boundary = manualBoundary {
Expand All @@ -72,7 +70,6 @@ open class UploadRequest<Operation: GraphQLOperation>: HTTPRequest<Operation> {
// for the files in the rest of the form data
let fieldsForFiles = Set(files.map { $0.fieldName }).sorted()
var fields = self.requestBodyCreator.requestBody(for: operation,
sendOperationIdentifiers: shouldSendOperationID,
sendQueryDocument: true,
autoPersistQuery: false)
var variables = fields["variables"] as? JSONEncodableDictionary ?? JSONEncodableDictionary()
Expand Down
4 changes: 3 additions & 1 deletion Sources/ApolloAPI/FragmentProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
/// any `Fragment` included in it's `Fragments` object via its `fragments` property.
///
/// - SeeAlso: `HasFragments`, `ToFragments`
public protocol Fragment: AnySelectionSet { }
public protocol Fragment: AnySelectionSet {
var fragmentDefinition: String { get }
}

// MARK: - HasFragments

Expand Down
7 changes: 7 additions & 0 deletions Sources/ApolloAPI/GraphQLNullable.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import Foundation

/// Indicates the presence of a value, supporting both `nil` and `null` values.
///
/// In GraphQL, explicitly providing a `null` value for an input value to a field argument is
/// semantically different from not providing a value at all (`nil`). This enum allows you to
/// distinguish your input values between `null` and `nil`.
AnthonyMDev marked this conversation as resolved.
Show resolved Hide resolved
///
/// - See: [GraphQLSpec - Input Values - Null Value](http://spec.graphql.org/June2018/#sec-Null-Value)
@dynamicMemberLookup
public enum GraphQLNullable<Wrapped>: ExpressibleByNilLiteral {

Expand Down
Loading