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

Add lazy function or explicitly explain how to deal with recursive decoders and encoders #24

Open
welf opened this issue Oct 13, 2017 · 4 comments
Assignees

Comments

@welf
Copy link

welf commented Oct 13, 2017

First of all thank you very much for your awesome package! ))

In Json.Decode module there is a function lazy which helps with building recursive encoders. Please define in your module GraphQL.Request.Builder the same function or explicitly explain in module docs how to deal with recursive decoding and encoding, because it confuses inexperienced Elm developers.

An example

After reading the module documentation I've understood I should define types as they are written in GraphQL schema (now I understand that your explanation of example has another meaning, but I am sure many other users will make a conclusion like mine one). To evade compilation error because of mutually recursive types I've followed recomendations of the compiler error message and defined types Tweets and Users:

type alias User =
    { id : String
    , name : String
    , slug : String
    , tweets : Tweets
    }

type alias Tweet =
    { id : String
    , text : String
    , author : User
    }

type Users
    = Users (List User)

type Tweets
    = Tweets (List Tweet)

Then I've tried to build GraphQL decoders as described in the example in GraphQL.Request.Builder docs:

userQuery : Document Query User { vars | slug : String }
userQuery =
    let
        userIDVar =
            Var.required "slug" .slug Var.id

        tweet =
            object Tweet
                |> with (field "id" [] string)
                |> with (field "text" [] string)
                |> with (field "author" [] user)

        user =
            object User
                |> with (field "id" [] string)
                |> with (field "name" [] string)
                |> with (field "slug" [] string)
                |> with (field "tweets" [] (map Tweets <| list tweet))
                |> with (field "followers" [] (map Users <| list user))
                |> with (field "following" [] (map Users <| list user))

        queryRoot =
            extract
                (field "user"
                    [ ( "slug", Arg.variable userIDVar ) ]
                    user
                )
    in
        queryDocument queryRoot

But as expected I've got a compilation error:

-- BAD RECURSION ---------------------------------------------- src/Commands.elm

`tweet` is defined in terms of itself in a sneaky way, causing an infinite loop.

15|>        tweet =
16|             object Tweet
17|                 |> with (field "id" [] string)
18|                 |> with (field "text" [] string)
19|                 |> with (field "author" [] user)

The following definitions depend directly on each other:

    ┌─────┐
    │    tweet
    │     ↓
    │    user
    └─────┘

You seem to have a fairly tricky case, so I very highly recommend reading this:
<https://github.com/elm-lang/elm-compiler/blob/0.18.0/hints/bad-recursion.md> It
will help you really understand the problem and how to fix it. Read it!

Detected errors in 1 module.

Elm-Compile exited abnormally with code 1

There is a link in that error explaining the bad recursion problem and advising how to solve it with Json decoders (with the help of lazy function), but it doesn't help with decoding recursive GraphQL decoders.

My proposition on how to improve module docs

I've found a solution after researching the outdated elm-graphql module docs.

First of all, make a clear statement in your docs that user should define types in Elm module not as they are present in GraphQL schema, but according to his GraphQL queries and mutations. Usually, people don't build deeply recursive queries like:

query FetchUserBySlug($slug: String!) {
  User(slug: $slug) {
    id
    name
    tweets {
      author {
        id
        name
        tweets {
          author {
            id
            name
            tweets {
              author {
		  ... etc
	      }
            }
          }
        }
      }
    }
  }
}

Much likely their GraphQL queries will be much less nested:

query FetchUserBySlug($slug: String!) {
  User(slug: $slug) {
    id
    name
    tweets {
      id
      text
    }
    followers {
      id
      name
      slug
    }
    following {
      id
      name
      slug
      tweets {
        id
        text
      }
    }
  }
}

I am sure you see that the last query is not only much less nested but also ends (as every other valid GraphQL request) with a scalar value. But when you think you should implement in Elm types from your GraphQL schema it is not so obvious. Thus please make accent that users have to define their Elm types according to their GraphQL requests, but not according to GraphQL types in their schema:

type alias FetchUserBySlugQuery =
    { id : String
    , name : String
    , tweets : List TweetInFetchUserBySlugQuery
    , followers : List FollowerInFetchUserBySlugQuery
    , following : List FollowingInFetchUserBySlugQuery
    }


type alias TweetInFetchUserBySlugQuery =
    { id : String
    , text : String
    }


type alias FollowerInFetchUserBySlugQuery =
    { id : String
    , name : String
    , slug : String
    }


type alias FollowingInFetchUserBySlugQuery =
    { id : String
    , name : String
    , slug : String
    , tweets : List TweetInFetchUserBySlugQuery
    }

Renaming types from User and Photo (like in your example) with the query- or mutation-related names will help to understand that types should reflect the query result, but not types in GraphQL schema! Please add the clear explanation that all query decoders should always end only with scalar decoders because otherwise they will be mutually recursive and will be rejected by the compiler.

I've ended with such decoder which was accepted by the compiler:

fetchUserBySlugQuery : Document Query FetchUserBySlugQuery { vars | slug : String }
fetchUserBySlugQuery =
    let
        userSlug =
            Var.required "slug" .slug Var.string

        tweet =
            object TweetInFetchUserBySlugQuery
                |> with (field "id" [] string)
                |> with (field "text" [] string)

        follower =
            object FollowerInFetchUserBySlugQuery
                |> with (field "id" [] string)
                |> with (field "name" [] string)
                |> with (field "slug" [] string)

        following =
            object FollowingInFetchUserBySlugQuery
                |> with (field "id" [] string)
                |> with (field "name" [] string)
                |> with (field "slug" [] string)
                |> with (field "tweets" [] (list tweet))

        user =
            object FetchUserBySlugQuery
                |> with (field "id" [] string)
                |> with (field "name" [] string)
                |> with (field "tweets" [] (list tweet))
                |> with (field "followers" [] (list follower))
                |> with (field "following" [] (list following))

        queryRoot =
            extract
                (field "user"
                    [ ( "slug", Arg.variable userSlug ) ]
                    user
                )
    in
        queryDocument queryRoot

Thanks again for your awesome package. I hope the explanation of my misunderstanding of the package docs will help other users successfully use your package and GraphQL.

@jamesmacaulay
Copy link
Owner

That's an excellent suggestion for making the documentation more clear :)

As for lazy-like functionality to allow for recursive decoders: I don't think anything quite like that is possible, because using the same spec nested "within itself" would result in an infinitely nested query that's impossible to serialize.

For what it's worth, I recommend having each query in its own module to make namespacing of the type aliases easier. Then most of the aliases would be like FetchUserBySlug.User and FetchUserBySlug.Tweet. You might then call the more minimal User representation UserSummary, or maybe UserRef if you only get its id. On the other hand, if you only get its id then you could also just extract it and put the id directly in an author_id field of the Tweet instead.

I will add a section about this stuff to the README and/or the GraphQL.Request.Builder module description.

@welf
Copy link
Author

welf commented Oct 16, 2017

Can you please add a code example of how to deal with complex arguments like filtering? I've spent a lot of time reading docs but didn't realized what to do ;(

Here is an example

I have such GraphQL schema:

type User @model {
  id: ID! @isUnique
  name: String!
  bio: String
  slug: String! @isUnique
  tweets: [Tweet!]! @relation(name: "Tweets")
  following: [User!]! @relation(name: "Followers")
  followers: [User!]! @relation(name: "Followers")
}

type Tweet @model {
  id: ID! @isUnique
  text: String!
  author: User! @relation(name: "Tweets")
}

... and want to fetch mutual followers of the user with the given slug:

query getMutualFollowers($slug: String!) {
  User(slug: $slug) {
    mutualFollowers: followers(filter: {AND: [{followers_some: {slug: $slug}}, {following_some: {slug: $slug}}]}) {
      slug
    }
  }
}

How can I construct an argument for followers to filter mutual followers? Filter properties are generated automatically by the Graph.cool backend and have a type [UserFilter] which is huge. Should I implement this type as union type in Elm to be able to filter queries? Here are properties of this type (they are generated for every field you add to your User type and for Tweet type and every other type there is an appropriate [TweetFilter] etc. types):

AND: [UserFilter!]
OR: [UserFilter!]
bio: String
bio_not: String
bio_in: [String!]
bio_not_in: [String!]
bio_lt: String
bio_lte: String
bio_gt: String
bio_gte: String
bio_contains: String
bio_not_contains: String
bio_starts_with: String
bio_not_starts_with: String
bio_ends_with: String
bio_not_ends_with: String
createdAt: DateTime
createdAt_not: DateTime
createdAt_in: [DateTime!]
createdAt_not_in: [DateTime!]
createdAt_lt: DateTime
createdAt_lte: DateTime
createdAt_gt: DateTime
createdAt_gte: DateTime
id: ID
id_not: ID
id_in: [ID!]
id_not_in: [ID!]
id_lt: ID
id_lte: ID
id_gt: ID
id_gte: ID
id_contains: ID
id_not_contains: ID
id_starts_with: ID
id_not_starts_with: ID
id_ends_with: ID
id_not_ends_with: ID
name: String
name_not: String
name_in: [String!]
name_not_in: [String!]
name_lt: String
name_lte: String
name_gt: String
name_gte: String
name_contains: String
name_not_contains: String
name_starts_with: String
name_not_starts_with: String
name_ends_with: String
name_not_ends_with: String
slug: String
slug_not: String
slug_in: [String!]
slug_not_in: [String!]
slug_lt: String
slug_lte: String
slug_gt: String
slug_gte: String
slug_contains: String
slug_not_contains: String
slug_starts_with: String
slug_not_starts_with: String
slug_ends_with: String
slug_not_ends_with: String
updatedAt: DateTime
updatedAt_not: DateTime
updatedAt_in: [DateTime!]
updatedAt_not_in: [DateTime!]
updatedAt_lt: DateTime
updatedAt_lte: DateTime
updatedAt_gt: DateTime
updatedAt_gte: DateTime
followers_every: UserFilter
followers_some: UserFilter
followers_none: UserFilter
following_every: UserFilter
following_some: UserFilter
following_none: UserFilter
tweets_every: TweetFilter
tweets_some: TweetFilter
tweets_none: TweetFilter

@welf
Copy link
Author

welf commented Oct 16, 2017

Wow! I've made it! 🥇

import GraphQL.Request.Builder exposing (..)
import GraphQL.Request.Builder.Arg as Arg
import GraphQL.Request.Builder.Variable as Var exposing (Variable)

...

mutualFollowersVar =
    Var.required "filter"
        .filterArg
        (Var.object "UserFilter"
            [ Var.field "AND"
                .andArgs
                (Var.object "UserFilter"
                    [ Var.field "followers_some"
                        .followersSome
                        (Var.object "UserFilter"
                            [ Var.field "slug" .slug Var.string ]
                        )
                    , Var.field "following_some"
                        .followingSome
                        (Var.object "UserFilter"
                            [ Var.field "slug" .slug Var.string ]
                        )
                    ]
                )
            ]
        )

mutualFollowersArg =
    [ ( "filter", Arg.variable mutualFollowersVar ) ]

userModel =
    object UserModel
        ...
        |> with
            (aliasAs "mutualFollowers" <|
                field "followers"
                    mutualFollowersArg
                    (list userBasicInfo)
            )

...

It looks terrible and has even more terrible type signature, but it works! ;)) And it doesn't require to write any additional Elm types for it.

@jamesmacaulay
Copy link
Owner

Oh wow...yeah that’s a pretty complex situation, good job for figuring it out! One thing though – if you wanted to end up with a query that’s equivalent to this one you provided:

query getMutualFollowers($slug: String!) {
  User(slug: $slug) {
    mutualFollowers: followers(filter: {AND: [{followers_some: {slug: $slug}}, {following_some: {slug: $slug}}]}) {
      slug
    }
  }
}

...then it looks like you originally wanted the $slug string as your only variable, correct? In your example, the variable you use represents the whole nested UserFilter object. If instead you wanted client code to only provide a single string as the variable, then you could define just that one variable and use it in both places within an argument structure built up with the functions in GraphQL.Request.Builder.Arg. Something like this (which I haven’t tested):

slugVar =
    Var.required "slug" .slug Var.string


mutualFollowersField =
    aliasAs "mutualFollowers" <|
        field "followers"
            [ ( "filter"
              , Arg.object
                    [ ( "AND"
                      , Arg.list
                            [ Arg.object
                                [ ( "followers_some"
                                  , Arg.object
                                        [ ( "slug", Arg.variable slugVar ) ]
                                  )
                                , ( "following_some"
                                  , Arg.object
                                        [ ( "slug", Arg.variable slugVar ) ]
                                  )
                                ]
                            ]
                      )
                    ]
              )
            ]
            (list userBasicInfo)

With the above code, the user would be able to supply { slug = “foo” } as the vars instead of { filterArg = { andArgs = { followersSome = “foo”, followingSome = “foo” } (...if in fact I got that right...).

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

No branches or pull requests

2 participants