Replies: 3 comments 4 replies
-
One issue I'm noticing given: struct CustomPerson: Query, GraphQLDecodable {
typealias Model = API.Model.Person
var name: PersonNameComponents
var email: String
var body: some Query {
Attribute(\.email)
Attribute(\.name) {
Attribute(\.givenName)
Attribute(\.familyName)
}
}
init(from decoder: Decoder) throws {
let email = try decoder.decode(\.email)
let given = try decoder.decode(\.name.givenName)
let family = try decoder.decode(\.name.familyName)
self.email = email
self.name = .init("\(given) \(family)")
}
} How does the GraphQLDecodable decoder use the KeyPath to resolve the value? It looks like it's the code generation that is responsible for creating the mapping between However, the two-hop KeyPaths (above) don't seem like they could be resolved effectively without having to code generate a case match for every single possible path through the GQL schema models. Specifically, I'm imagining a code generated function like this: func lookup<Value>(_ kp: KeyPath<API.Model.Person, Value>) -> String? {
switch kp {
case \.email: "email"
case \.name: "name"
}
} Such that the decoder would have some code like this: func decode<Value>(_ kp: KeyPath<API.Model.Person, Value>) throws -> Value {
guard
let topLevelMemberName = lookup(kp),
let value = self.jsonDataStructure[topLevelMemberName] as? Value
else { /* throw */ }
return value
} That seems fine for top level members. But (unless I'm missing something) it seems to break down with nested queries. func lookup<Value>(_ kp: KeyPath<API.Model.Person, Value>) -> [String] {
switch kp {
case \.email: ["email"]
case \.name: ["name"]
// Having to do this ends up creating explosive combinatoric
// over every possible KeyPath through the GQL schema
case \.name.givenName: ["name", "givenName"]
}
}
func decode<Value>(_ kp: KeyPath<API.Model.Person, Value>) throws -> Value {
guard
let memberPath = lookup(kp),
let value = self.jsonDataStructure.recursiveLookup(memberPath) as? Value
else { /* throw */ }
return value
} Possible Solutions
Is there something I'm missing? |
Beta Was this translation helpful? Give feedback.
-
This looks great - did this work eventuate? The whole repo looks very quiet since before WWDC. |
Beta Was this translation helpful? Give feedback.
-
I am curious. Any update on the v6 work? It's almost a year since the talk in #185. |
Beta Was this translation helpful? Give feedback.
-
Consumer API
Overview
The current APIs have a few issues IMO that I'd like to try and resolve.
It was some amazing upsides, but I feel the developer experience leaves a lot to be desired and the current approach is problematic to really solve that well.
Proposal
Given that, I started working on several different approaches over several months. Eventually I landed on a
resultBuilder
implementation that I feel solves all of the above problems, and only introduces very minor (almost irrelevant) trade-offs.Lets cover the issues and I'll demonstrate how I feel the new approach could improve on those issues.
Learning curve and auto-complete
I'm a firm believer in leaning into the plethora of API Apple has provided since developers are already likely familiar and therefore have certain expectations that we can easily fulfil. SwiftUI is a great example, so taking a queue from that writing a Query is now much simpler.
As you can see it looks extremely familiar, however it's even simpler than SwiftUI since there is only ONE type to learn,
Attribute
.The
Model
(a protocol requirement) works in tandem with theQueryBuilder
to provide full type-safety and autocomplete support against the generated models.And since only models are scoped to
API.Model
its easy to find the appropriate type. From there auto-complete helps you discover everything else without issue.Reusable queries
Another great feature of
resultBuilder
APIs, is that its easy to write and composeQuery
's. Lets say we had a query with a nested element:We can easily decompose the
name
by simply copying its content and pasting it into a newQuery
:And now our updated
PersonQuery
becomes:As you can see, it's an extremely flexible API and makes it easy to write, compose and decompose queries. It's uncomplicated, extremely familiar and easy to work with.
Swift language
Lastly I want to touch on the language. Here you only need to remember TWO terms,
Query
andAttribute
– both of which are familiar terms that have well understood semantic meaning.Unlike SwiftUI you don't need to work with many types of View's or other types, everything is either built-in or generated, but is provided via extremely discoverable auto-complete APIs.
In fact, API availability and behaviour is also scoped to the initialisers similarly to most SwiftUI APIs. For example nesting a query under
name
that isn't scoped toAPI.Model.Name
will fail to compile with an error indicating as such.The same applies for nesting, trying to include nesting on a non-nested type (dict or array) simply fails to compile. So the compiler support is strong at all times.
Added benefits
I'd like to also discuss some of the added benefits this approach brings over the existing implementation (and others I explore).
Actor support
When you execute a query, the
Client
automatically scopes the query to theGraphQLActor
. This ensures thread-safety end-to-end for any given query. This includes query execution right up until model decoding and the developer doesn't have to do anything to get this behaviour.Aliasing
Wherever possible the API provides strongly typed compile-time warnings or errors to ensure you can't compile a query that isn't correct. The one exception to this is related to attributes being unique at any given level.
This is a GraphQL requirement since queries are represented as JSON during transport. The existing v5 implementation uses an automatic hashing approach to ensure you don't need to think about this. However in practice this can be problematic and there's no easy way to opt-out and control that behaviour if you need to.
The new approach uses the following approach:
Attribute
names are respected by defaultalias
can be provided giving you full controlRequest
executes thebody
of the query, the library collects the attributes and throws an error if it encounters a duplicateIn addition, you can set a
naming policy
on theRequest
that changes this behaviour. The alternative strategy uses hashes similar to the existing implementation in an attempt to 'solve' this for the developer, the tradeoff being you give up some control.Conditional queries
Another added benefit of using
resultBuilder
APIs, is that we can write conditional queries:This is of course possible in v5 as well, but it's slightly cleaner here since we can write nested queries more easily as well.
Note also how we're able to store a variable for controlling our condition. This feels extremely natural and is a powerful way to build reusable
Query
's that are then used in other contexts.Generated models
API.Model.xxx
types are generated models from your GraphQL Schema. Therefore a lot of the existing implementations will likely remain with some subtle improvements.These will also continue to be automatically decoded by the library through the definition of
Scalar
types. These are similar to the existing protocol however with a slightly more up-to-date and familiar API. It leans more heavily into theCoreTransferable
for design inspiration.The library includes default implementations for many commonly used types and if your custom type can be simple
cast
you can opt-in more easily:No implementation is required, since a default is already provided:
However you can instead implement a custom representation for a type if you need greater control:
Decoding
One of the strongest implementations (imo) of the existing API that Mat did a great job on, is how the decoding happens alongside the query generation. While this is not my personal preference, it does solve a unique problem, where my solution decides to make the trade off.
In the current implementation, you are essentially 'decoding' at the same time as you're writing the 'selection' – this is really cool because it means you can't forget to 'select' something you want to 'decode'.
However, it's slightly unnatural to me (and I'd assume most other Swift developers) given other API like
Decodable
. In addition, it makes it difficult to separate concerns (engineering principle) between query definition and model decoding.In addition there are caveats you need to take of as a developer and without reading the docs, can lead to confusion. In fact, Mat has a special note on the website specifically for this.
Given that, I decided to take an alternative approach that borrows from some of Mat's ideas but pushes in a different direction.
Model
typealias requirement is identical to aQuery
you could choose (if preferred) to share this model with both your query and the decoding. So it's still possible to keep it on a single type, file etc if you prefer.Decoder
pattern is familiar and makes it instantly clear in terms of expected behaviour.Decode will
throw
an error if the value doesn't exist since we're inferring here that it should.Giving the developer more control means you can do things like decide to never
throw
, rather just revert to a default value:This approach is preferable IMO because we ensure the user has maximum flexibility while keeping things intuitive and simple. However we include convenience where appropriate, but not at the expense of customisation where that's the common-case.
Final thoughts
There are still a lot of details to work through, while I do have a compiling/working POC for the above, there are many pieces left to sort through. For one thing many nested attributes accept 'arguments' – therefore an API must be generated for those.
This will be possible since the code-gen will simply extend
Attribute
to include thoseinit
methods. But this needs to be fleshed out of course.I look forward to sharing more and I am open to any and all feedback at this stage, no solution is perfect but I feel like this has the right trade-offs while still improving the current experience and taking it even further.
Beta Was this translation helpful? Give feedback.
All reactions