Dynamic GraphQL Query Generation In Swift

Alex Pelletier
Building Ibotta
Published in
6 min readMar 20, 2020

This article is going to assume you are familiar with GraphQL.

Historically it has been best practice to use static GraphQL queries using either .graphql files or in-code string literals. One of the most popular mobile GraphQL clients, Apollo, uses static GraphQL files to generate models. We used Apollo in this manner for a year, but ran into performance issues with decoding speed and cache read speed. At Ibotta we've recently fully migrated from Apollo to a custom GraphQL framework where we generate queries based on data models. We are now able to performantly (< 10ms) generate queries while supporting partial cache hits.

To move away from generated models we will need to declare our own Decodable structs to decode the returned JSON. But it would be hard to keep custom Decodable structs in sync with our static GraphQL queries. In GraphQL we have to ask for every field we want returned, so what if we use our response structs as the source of truth to generate queries? Doing this creates a sort of declarative GraphQL interface. Consider if we could do something like:

And convert Query.self to:

This strategy creates a single source of truth for queries in code, and keeps the queries close to the code that uses it.

Implementation

This article is going to go over a simple implementation to generate GraphQL queries from struct. We aren’t going to get into every nuance, but the underlying code will be open sourced soon if you want to really dig in. The implementation is going to get fairly intense so feel free to pause here, and grab a fresh cup of ☕️.

Query Generation

We don’t want to use reflection to generate query from our models; one of the main reasons is we want to deliberately decouple our coding keys from our model property names. Another reason is that by not using reflection we can enable much more complex operations of our models.

So how do we go from a struct to a query? We are first going to map our query model into a tree and use that tree structure to generate a query. This is easier said than done. The underlying concept that is going to power this is creating injectable decoders. Generally when working with Decodable models we use the standard JSONDecoder, but there is no reason we couldn't use a custom decoder. We are going to use a custom decoder to create a dummy model and keep track of what fields are requested.

In order to do this, we are going to create a new Decodable protocol called DataDecodable. We could use the standard Decodable protocol, but GraphQL has some special requirements that we are going to want to support as first class citizens. This protocol is super simple:

If you look closely you can see we also defined a custom decoder called DynamicDecoder. This will be the base class we use to subclass all of our decoders. We opted to subclass our decoders over a decoder protocol because working with a generic protocol requires extra overhead (think associated type requirement issues). The DynamicDecoder will be generic over CodingKeys; very similar to the standard lib's Decoder.

Our decoder has only three simple methods, this is all that is needed to decode almost any GraphQL payload. All primitives can use the .from(:ofType:) decoding method, but the type must conform to EmptyInitializable. This will allow us to create a dummy model while creating an object map. Each of these three decoding methods has an ofType parameter used to specify the expected return type, but generally type T can be inferred.

The syntax for DataDecodable models ends up being very similar to the standard library's Decodable. We can create a DataDecodable struct like:

To keep a global list of available GraphQL fields we can define a protocol for each GraphQL type.

Query Representation as a Tree

We want to convert our DataDecodables to an intermediate tree, and then convert that tree to a query. But what should that tree structure look like? We will want to maintain certain information like field name, children, and type. Type should be represented in a way that GraphQL types can also be converted to. Lets start with a tree structure like this:

Using an indirect enum for ObjectType allows us to represent [User] as .list(.object("User")). We don't need this granularity of type information to generate our queries, but while debugging we might want to validate the query against the schema. At Ibotta we validate all queries against the schema while debugging. This helps us be confident we are shipping accurate queries that won't break.

With this object map approach the above Query would be represented as:

But we still need some way to actually generating these ObjectMap s that can then be turned into queries.

MappingDecoder

We are going to create a new DynamicDecoder called a MappingDecoder that is going to create a dummy struct, and map the fields that are requested. Each time a field is requested we are going to append that field to a children array, and if an object is requested we are going to recursivly map all children of that object. Because all primitives need to conform to EmptyInitializable we can create and decode a dummy struct while mapping. This can be done like:

Notice how child objects are recursively mapped to create a full ObjectMap of the entire struct. This is just one example of the powerful and extensible interface into our models DynamicDecoders + DataDecodables provided.

Because Query is a DataDecodable we can generate an full object map like:

Just like that we have converted our strongly typed struct into a tree without reflection. Now that our query is in an easy to use format we can work on generating a query.

Query Generation

We are now going to convert our users ObjectMap to:

To keep things performant the query generation will directly convert an ObjectMap into a string query.

We are going to need a helper function to recursively convert an ObjectMap to a query string. This helper function will build the query from the bottom up, as a depth first search. This will allow us to merge like fragments and reduce upload size. To know if we can use a previously seen fragment we will compare fragment bodies and fragment types.

As we convert an ObjectMap to a query, and if the ObjectMap represents a primitive, we will return the field name.

Now all that is left to generate our query is finishing building out the query(from:) method. We will need to merge all of our fragments into the string query. A GraphQL fragment has a format of:

Our Fragment struct has each of these three fields so we can just map our fragments to a string and then join them. We can adjust our query(from:) like:

Just like that we’ve converted our query struct to a tree and then to a GraphQL query. All of this is done in two passes, making the whole generation a linear time operation. We can now use our QueryBuilder like:

One thing we didn’t go over is how to embed query parameters. Query parameters can be injected into the QueryBuilder and merged in during query generation.

Dictionary Decoder

So we’ve converted our query struct into a string query; now what? Ultimately, we want to hit our servers and return an instance of Query. Assuming we are able to send the string query to our server successfully and get back JSON, we need some way of creating an instance of Query from the JSON. We are going to need to create another DynamicDecoder to decode a DataDecodable using JSON.

Similar to our MappingDecoder, we are going to recursively decode a DataDecodable. However, our new DictionaryDecoder will be initialized with a JSON payload. If a JSON key is missing we will want to throw an error.

Conclusion

Ibotta uses this query generation strategy in our product to deliver a lightning fast experience to millions of users. When used in conjunction with a normalized cache we are able to get high rates of full / partial cache hits. We also saw big decoding speed improvements using this strategy because we are decoding into simple struct.

Ibotta hopes to soon open source our full query generation networking library named Koios.

We’re Hiring!

If these kinds of projects and challenges sound interesting to you, Ibotta is hiring! Check out our jobs page for more information.

--

--