-
Notifications
You must be signed in to change notification settings - Fork 722
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
Conversation
There was a problem hiding this 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 😊
Tests/ApolloTests/StarWars/API.swift
Outdated
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 |
There was a problem hiding this comment.
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
}
}
}
There was a problem hiding this comment.
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!
?
There was a problem hiding this comment.
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.
Tests/ApolloTests/StarWars/API.swift
Outdated
|
||
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))), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Tests/ApolloTests/StarWars/API.swift
Outdated
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
public final class HeroAndFriendsNamesWithIDsQuery: GraphQLQuery { | ||
public static let operationDefinition = | ||
public static let operationString = | ||
"query HeroAndFriendsNamesWithIDs($episode: Episode) {" + |
There was a problem hiding this comment.
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 😊
Tests/ApolloTests/StarWars/API.swift
Outdated
public init(values: [Any?]) { | ||
__typename = values[0] as! String | ||
name = values[1] as! String | ||
asHuman = values[2] as! AsHuman? |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Tests/ApolloTests/StarWars/API.swift
Outdated
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), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Tests/ApolloTests/StarWars/API.swift
Outdated
public init(values: [Any?]) { | ||
__typename = values[0] as! String | ||
name = values[1] as! String | ||
friends = values[2] as! [Friend?]? | ||
} | ||
|
||
public struct Friend: GraphQLMappable { |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
@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 :) |
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? |
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, 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 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! |
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. |
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 |
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 |
🎉 |
No description provided.