juniper icon indicating copy to clipboard operation
juniper copied to clipboard

Statically typed look aheads

Open davidpdrsn opened this issue 6 years ago • 6 comments

In juniper-from-schema we have a feature called QueryTrail. That is basically a type safe look ahead that guarantees that you only check for valid fields. I've been thinking if something like that would make sense to have in juniper. I've also thought of a way to implement it.

How QueryTrail works in juniper-from-schema

The current look ahead API looks something like this:

let lh = executor.look_ahead();

let lh = lh.select_child("user")
  .and_then(|lh| lh.select_child("city"))
  .and_then(|lh| lh.select_child("country"));

if let Some(_) = lh {
  // user.city.country was part of the query
}

This works well but has two downsides

  • Because select_child returns an Option you have to chain with and_then or ?. That isn't very ergonomic.
  • The keys are strings so you risk making typos or otherwise checking for fields that don't even exist.

In juniper-from-schema I solve these issues with an abstraction I call QueryTrail. It is basically a wrapper around a LookAheadSelection with a PhantomData for the particular type in the schema the LookAheadSelection corresponds to. The definition is:

pub struct QueryTrail<'a, T, K> {
  pub look_ahead: Option<&'a LookAheadSelection<'a, DefaultScalarValue>>,
  pub node_type: PhantomData<T>,
  pub walked: K,
}

walked: K is to know statically whether the look ahead path you've built up has been checked against the query. K will always be one of two types:

struct Walked;
struct NotWalked;

Imagine we have a schema like this:

type Query {
  user: User
}

type User {
  city: City
}

type City {
  country: Country
}

type Country {
  id: ID
}

At compile juniper-from-schema walks over your schema and adds methods to QueryTrail for the fields of each type. Something like:

impl<'a, K> QueryTrail<'a, Query, K> {
    pub fn user(self) -> QueryTrail<'a, User, NotWalked> {}
}

impl<'a, K> QueryTrail<'a, User, K> {
    pub fn city(self) -> QueryTrail<'a, City, NotWalked> {}
}

impl<'a, K> QueryTrail<'a, City, K> {
    pub fn country(self) -> QueryTrail<'a, Country, NotWalked> {}
}

impl<'a, K> QueryTrail<'a, Country, K> {
    pub fn id(self) -> bool;
}

For each resolver you then get a QueryTrail where T is the type the resolver returns:

impl QueryFields for Query {
    fn field_user<'a>(
        &self,
        _: &Executor<'a, Context>,
        trail: &QueryTrail<'a, User, Walked>,
    ) -> FieldResult<User> {
        // ...
    }
}

This QueryTrail is constructed using MakeQueryTrail.

Since we get a QueryTrail<'a, User, Walked> we're only allowed to call .city(). That is the only method we generated for QueryTrail<'a, User, K>. For example we cannot call .country() because the requires a QueryTrail<'a, City, K>, which we don't have. This is how we're guaranteed to not look for fields that don't exist on the current type.

There is one more important method on QueryTrail:

impl<'a, T> QueryTrail<'a, T, NotWalked> {
    pub fn walk(self) -> Option<QueryTrail<'a, T, Walked>> {}
}

Notice that .user(), .city(), etc, all return QueryTrail<'a, T, NotWalked>. To turn this into a QueryTrail<'a, T, Walked> we have to call .walk(), which returns an Option<QueryTrail<'a, T, Walked>>. That means we're also forced to check that we get a Some back.

The implementation of .walk() just to check that the look_ahead inside self is a Some and if so return a QueryTrail<T, Walked>.

All this put together means we can write code like this:

fn eager_load_countries<'a>(
    country_ids: &[i32],
    trail: &QueryTrail<'a, Country, Walked>,
    ctx: &Context,
) -> Vec<Country> {
    // ...
}

if let Some(country_trail) = trail.user().city().country().walk() {
    eager_load_countries(&country_ids, &country_trail, ctx);
}

By taking QueryTrail<'a, Country, Walked> we're guaranteed that country is part of the query, because the only way to get a Walked is to call .walk() which performs the check an Option.

This means we're guaranteed that we don't eager load unnecessary data. This is exactly what juniper-eager-loading does.

We also generate code for looking at arguments. You can read about that here.

Aside about inherent impls

Actually we don't generate the methods directly on an impl QueryTrail block like shown above. That isn't possible because QueryTrail is defined in juniper-from-schema itself. It is defined there such that juniper-eager-loading can depend on it. If QueryTrail was put directly into the user's code that wouldn't be possible.

So the way we actually add the methods is through extension traits. For each type we generate a trait called something like QueryTrailUserExtension which has methods for each field. All those traits are put in the same module so users can import them with use query_trail::*.

Adding QueryTrail to juniper

The fact that the methods are added to QueryTrail via extension traits technically means that we don't need to know the whole schema up front. #[juniper::object] could also generate these traits.

There is an issue of making it easy for the users to import the traits so they can call the methods. I'm not quite sure how to solve it. I would imagine the compiler would be good at suggesting what to import however. Since we don't generate all the traits all at once we can't easily put all of them in the same module.

I see there is a fair of type "magic" going on but I think the guarantees you get are very useful.

What do you think? Is this something that makes sense to add to juniper? If so I'll cook up a PR.

davidpdrsn avatar Oct 31 '19 16:10 davidpdrsn

This definitely makes sense, and I have very much wanted type safe look-ahead myself previously.

When thinking about this a few months ago I came up with a slightly different design approach that doesn't require traits.

Basically it involves generating a lookahead struct for each object type and having an extra type Lookahead on GraphQLType.

It has the benefit of working without importing extension traits and not requiring a walk() method.

trait GraphQLType {
...
  type Lookahead;
}

Example:

struct User;

#[object]
impl User {
  fn best_friend(&self) -> Option<User> { ... }
}

// GENERATED code:

impl GrahQLType for User {
  ...
  type Lookahead = UserLookahead;
}



struct UserLookahead<'a> {
  selection: &'a juniper::LookaheadSelection,
}

impl UserLookahead {
    fn best_friend(&self) -> Option<<User as GraphQLType>::Lookahead> { ... }
}

theduke avatar Oct 31 '19 17:10 theduke

Ah, I just noticed that this can not work at the moment: it would require GAT (generic associated types) to be implemented, since the lookahead type contains a lifetime...

theduke avatar Oct 31 '19 17:10 theduke

Hm true. I wonder what the implications would be to add that lifetime to GraphQLType so

impl<'a> GrahQLType<'a> for User {
    type Lookahead = UserLookahead<'a>;

    fn resolve_field(
        &self,
        info: &(),
        field_name: &str,
        args: &Arguments,
        executor: &Executor<'a, Database>,
    ) -> ExecutionResult {
        // ...
    }

    // ...
}

Having UserLookahead::best_friend return an Option is also a minor inconvenience if you want to look for deeply nested fields. Something like user_look_ahead.best_friend().city().country(). Then you end up chaining either with and_then or ?.

Whether this actually comes up in practice I don't know. In my experience I mostly look "one level down" and in that case it isn't an issue.

davidpdrsn avatar Oct 31 '19 17:10 davidpdrsn

Having UserLookahead::best_friend return an Option is also a minor inconvenience

True. I actually think your approach is better. We can always return the lookahead type and add a walk() or resolve() method to get an actual value out at the end.

Makes more sense.

I wonder what the implications would be to add that lifetime to GraphQLType so

Sadly not possible since the lifetime is not related to the type implementing the trait. The lifetime would have to be stored in a PhantomData, which is not possible here.

Actually we could remove the lifetime requirement by cloning the data in the lookahead. It's a tree of selected fields and arguments , so it's not terribly expensive to clone. We would need to clone again though at every nested step. (Or put the tree in a Arc to the top level selection and have the lookahead types keep track of the nested path in a Vec<String> )

Note that this design would actually also require:

trait LookaheadBuild {
  fn build(sel: &LookaheadSelection) -> Self;
}

trait GraphQLType {
  type Lookahead: LookaheadBuild;
}

theduke avatar Oct 31 '19 17:10 theduke

I actually think your approach is way more clever/elegant, due to the type machine style of Walked/NotWalked and not requiring a custom struct for each object type.

The downside is the need to import the various traits. juniper-from-schema could generate a prelude module that contains all this, but with regular juniper the compiler messages could be a bit confusing and importing a trait that doesn't exist in your code would feel a little bit weird. (eg use users::UserLookaheadExt)

The downside to the alternative is potential cloning of the selection tree.

I'll think a bit more about which approach is better, and I'm happy to hear your thoughts (and others).

theduke avatar Oct 31 '19 17:10 theduke

Sadly not possible since the lifetime is not related to the type implementing the trait.

Are you sure? I do something similar in juniper_eager_loading::EagerLoadChildrenOfType. That trait has a lifetime which is only used in the QueryTrail argument of one method.

trait EagerLoadChildrenOfType<'a, ...> {
    fn eager_load_children(
        nodes: &mut [Self],
        models: &[Self::Model],
        db: &Self::Connection,
        trail: &QueryTrail<'a, Child, Walked>,
        field_args: &Self::FieldArguments
    ) -> Result<(), Self::Error> { ... }
}

Making implementations like

impl<'a>
    EagerLoadChildrenOfType<
        'a,
        Country,
        EagerLoadingContextUserForCountry,
    > for User
{
    type FieldArguments = CountryUsersArgs<'a>;
    ...
}

works fine. There is a complete example here.

There might be some subtleties I'm missing.


One thing we should keep in mind though is that it must be possible to build libraries that depend on these look ahead types. That might influence the design. However both

fn eager_load_countries(
    country_ids: &[i32],
    trail: &QueryTrail<'_, Country, Walked>,
    ctx: &Context,
) -> Vec<Country> {
    // ...
}

or

fn eager_load_countries(
    country_ids: &[i32],
    trail: &<Country as juniper::GraphQLType>::LookAhead,
    ctx: &Context,
) -> Vec<Country> {
    // ...
}

would probably work fine.


but with regular juniper the compiler messages could be a bit confusing and importing a trait that doesn't exist in your code would feel a little bit weird

Yes that is pretty awkward 😕 If only Rust had a "all proc-macros are done" sorta callback 😜

davidpdrsn avatar Oct 31 '19 18:10 davidpdrsn