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

New query execution model [WIP] #63

Merged
merged 18 commits into from
Mar 8, 2017
Merged

Conversation

martijnwalraven
Copy link
Contributor

No description provided.

Copy link

@calebmer calebmer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍

I made some comments bringing up some edgecases I’ve encountered when working on JS GraphQL clients, and asked some questions to help further my knowledge of Swift.

From a high level (and correct me if I’m wrong) it looks like the reader takes a dynamic GraphQL AST-like format and reads it from the cache into a blob of arrays (on second look they may actually be associative arrays) which the generated code then turns into typed-Swift data structures.

Another thing that threw me at first is it looks like what the JS client calls “write” it looks like you call “read.” We “write” a result to the store, but iOS “reads” a dynamic JSON object into typed objects. Kind of ironic 😊

stars = try reader.value(for: Field(responseName: "stars"))
commentary = try reader.optionalValue(for: Field(responseName: "commentary"))
public init(values: [Any?]) {
__typename = values[0] as! String

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did values change to arrays vs. dynamic dictionaries? I’m also curious how this would work for a query like:

{
  hello
  world
  hello
  alias: world
}

Or:

{
  foo {
    a b
    bar {
      d
    }
  }
  foo {
    c
    bar {
      e
      f
    }
  }
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, what’s up with as!? Are the types verified at runtime before an as!?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The difference is that before, code like reader.value(for: Field(responseName: "stars")) would be driving the parsing. There was no separate runtime representation of the query, so accessing a value would both resolve the value (from response or cache) and convert it to the right type.

The new execution model uses a runtime representation to basically implement the GraphQL execution model from the spec, with some changes. It merges selections sets and collects fields for resolving (so we only resolve once), but it still keeps track of the original selections so we can instantiate the right types.

So for a query like:

query HeroAndFriendsNames {
  hero {
    name
    ...HeroDetails
    friends {
      name
    }
}

fragment HeroDetails on Character {
  id
  friends {
    appearsIn
  }
}

friends would only be resolved once, and we would then resolve both name and appearsIn for all friends. But we would still instantiate a separate HeroAndFriendsNames.Data.Hero.Friend and HeroDetails.Friend from that same information.

The as! is as forced cast, because the execution model is responsible for instantiating/converting the right types.


public static let selectionSet: [Selection] = [
Field("createReview", arguments: ["episode": "JEDI", "review": ["stars": 10, "commentary": "This is awesome!"]], type: .object(Data.CreateReview.self), selectionSet: [
Field("__typename", type: .nonNull(.scalar(String.self))),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How similar is this format to the JS GraphQL AST? Would it make sense to just make the format as close to the AST format as possible?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could also make it easier to just send over a JSON AST blog from the React Native JS thread.

Copy link
Contributor Author

@martijnwalraven martijnwalraven Feb 23, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is already as close as possible to make sense. It has to contain some extra information (like types), and it is meant to be easily interpretable at runtime.

}
}
}
}
}

public final class HeroAndFriendsNamesWithIDsQuery: GraphQLQuery {
public static let operationDefinition =
public static let operationString =
"query HeroAndFriendsNamesWithIDs($episode: Episode) {" +

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like that the generated output keeps the line breaks for the queries. It’s not obvious, but it’s a small DX thing that makes me happy 😊

public init(values: [Any?]) {
__typename = values[0] as! String
name = values[1] as! String
asHuman = values[2] as! AsHuman?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the user access data in fragments like: hero.asHuman.height (with any appropriate type checks of course). I’d love to know why this was done and the general rules around how it works. What are the other approaches? Does Swift have a really good enum/data type like Rust or Haskell that works well with exhaustive pattern matching?

Copy link
Contributor Author

@martijnwalraven martijnwalraven Feb 23, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, fragments (both named ones and merged object type specific ones like asHuman) are treated as separate types. I started out with a model where these fragments were generated as protocols ('interfaces') and the query-specific selection sets implemented them, but that broke down due to limitations of the Swift type system (in short, properties defined in protocols are invariant).

Swift has enums with associated values as a way to do exhaustive pattern matching, but that isn't really a natural fit for cases like this.

Field("hero", arguments: ["episode": Variable("episode")], type: .object(Data.Hero.self), selectionSet: [
Field("__typename", type: .nonNull(.scalar(String.self))),
Field("name", type: .nonNull(.scalar(String.self))),
FragmentSpread(Data.Hero.AsHuman.self),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like you’re generating a fragment here for inline fragments. What happens when you have an inline fragment without a type condition?

{
  a
  b
  ... {
    c
    d
  }
}

I don’t know anyone who uses inline fragments without a type condition, but it is technically possible. Theoretically I can see them being useful with directives, and in the future we may use them in Apollo Client JS to allow users to specify fields to partially read from the store. Say the user always wanted fields a and b, but only wanted fields c and d if they were available in the store and if they are not available then the user just wants a and b without an error being thrown.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline fragments without a type condition (or with a type condition that always matches) currently get merged into the parent selection set by apollo-codegen. I don't think they would help avoid cache misses, because if c and d are unavailable, reading the selection set as a whole would still fail. In those cases, a named fragment with just a and b might make more sense.

public init(values: [Any?]) {
__typename = values[0] as! String
name = values[1] as! String
friends = values[2] as! [Friend?]?
}

public struct Friend: GraphQLMappable {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you generate type names like this? It looks to me like here you would be singularizing the friends field name to get Friend, yes? Are there any chances for collisions? Is there any reason a user would want a clean type name vs. something that is less prone to collision like public struct field__friends__1: GraphQLMappable?

Copy link
Contributor Author

@martijnwalraven martijnwalraven Feb 23, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we singularize friends to Friend. There is the potential for collision, so I have been worried about that. I prefer clean type names though, because they do show up in autocompletion, and you need to specify them when storing values.

}
}

private func orderIndependentKey(for object: JSONObject) -> String {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should really make sure that JS key generation is order independent 😣

Luckily no one has reported any related bugs yet.

@martijnwalraven
Copy link
Contributor Author

@calebmer: Thanks for taking a look at this! I Definitely useful to have some more eyes on the design.

The structure you see here is actually responsible both for reading from the cache and from a network response. And it does normalization in parallel with reading. It is basically the standard GraphQL execution model, with the ability to turn a result into typed structs instead of a flattened JSON response. I should really write some of this down :)

@calebmer
Copy link

I didn’t know that it also handled reading from the cache 😊

So this PR is just concerned with turning abstract data blobs into typed structs?

@martijnwalraven
Copy link
Contributor Author

martijnwalraven commented Feb 27, 2017

Turning data blobs into typed structs is definitely an important part of what it does, but it is meant to be a bit more generic. I realize this is turning into a giant PR, but it all seems to be tied together, and I'm actually quite happy with how it is turning out!

At a high level, GraphQLExecutor is responsible for executing a selection set against a resolver function and returning a result. It implements the GraphQL execution model, which includes merging nested selection sets, so you only have to resolve once. That is especially important for reading from a persistent cache.

While it executes selection sets and completes values, it turns these values into results. Currently, that includes both turning them into typed structs and into normalized records, in one pass. (The normalized records are then published to the store, and the typed structs returned to the client.)

I'm working on a generalization of this, because we now need a third result type, a JSON response blob suitable for sending to React Native. I'm introducing the notion of a GraphQLResultAccumulator, which receives values from the executor and turns these into a result. By 'zipping' accumulators, you can get any combination of result types at the same time. So if you perform a fetch from React Native, you want a JSON response blob and normalized records for instance, but there is no need for typed structs. If you don't need caching, you could even skip normalization completely.

These changes to the execution model also tie in with something else we need, the ability to return promises from the resolver function, so we can read asynchronously from the persistent cache (and use something like data loader to get batching). I have a basic version of this working, but it broke the current normalization model, so that's another reason for going down the accumulator route.

I hope this makes some sense!

@martijnwalraven
Copy link
Contributor Author

Oh, the plan is also to use this to implement the store write API. In that case, you would only do normalization, and then merge the records into the store.

@calebmer
Copy link

Why couldn’t you generate JSON from the typed structs?

Also, I didn’t think about implementing the GraphQL execution algorithm for writing to the store (is that what you said?). Could you explain a bit more what you mean there? Is your implementation somewhat like graphql-anywhere in concept (although graphql-anywhere does not strictly implement the execution model).

@martijnwalraven
Copy link
Contributor Author

You could generate JSON from the typed structs, although to be useful that probably means we'd have to allow them to be mutable. But the point here is that even if you generate JSON from them, that is just the first step.

To write to the store, you eventually want to end up with a normalized record set (records with cache keys and all embedded objects replaced by references to other records). And that is exactly what the executor already has to do for network responses. So for writing, you can configure the executor with a resolver function and the normalizer, execute the selection set, and then publish the normalized results to the store.

You don't even have to generate JSON from the typed structs first I think. We could write a resolver function that knows how to read from the typed structs directly. And the nice thing about the whole model is that we can support both typed and untyped input without changing the rest of the pipeline (we need to support JSON input for writing to the store from React Native).

The model is very similar to graphql-anywhere I think, with the difference being that it supports pluggable result types (accumulators).

@martijnwalraven martijnwalraven mentioned this pull request Mar 3, 2017
@martijnwalraven martijnwalraven merged commit 56f1674 into master Mar 8, 2017
@martijnwalraven martijnwalraven deleted the dynamic-operations branch March 8, 2017 16:56
@stubailo
Copy link
Contributor

stubailo commented Mar 8, 2017

🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants