samdenty / gqless

a GraphQL client without queries
https://gqless.com
3.66k stars 54 forks source link

Support graphql interfaces (node interface from relay spec server) #215

Closed outerlook closed 3 years ago

outerlook commented 3 years ago

Take an example schema:

interface Node {
  id: ID!
}

type Product implements Node {
  id: ID!
  name: String
  price: float
}

type Customer implements Node {
  id: ID!
  name: String
}

type Query {
  customers: Customer[]
  products: Product[]
  node(id: ID!): Node
}

This kind of schema creates some possibilities for fetching independent Nodes. We don't have to create queries product(id) or customer(id)

if this ID is uniquely indentified accross all nodes, node(id) can find any object that implements node's and return it.

for example:

{
    node(id: "[ID]") {
    ... on Customer {
      id
      name
    }
  }
}

serves for this purpose. it create's possibilities of using relay's pagination schema with connections, etc which is highly scalable.

right now, when using it on gqless like this:

 resolved(() => {
        const customer = query.node({ id: customerId }) as Customer
        const { __typename, id, name } = customer
        return { __typename, id, name}
    })

the produced query is

"query($id1:ID!){node0:node(id:$id1){__typename id}}"

so it doesn't support fetching objects fields that implements Node.

You can read more complete examples of this usage on Documentation for relay's graphql server specs

vicary commented 3 years ago

Didn't notice this issue, but if true this will be the main thing stopping me from trying gqless in production right now.

wjohnsto commented 3 years ago

This is definitely an interesting issue, and something that is implemented in many GQL APIs. The nature of gqless currently works incredibly well when the shape of your GQL schema mimics that of ordinary JS objects. GQL interfaces present a divergence from the way ordinary JS objects work.

{
    node(id: "[ID]") {
    ... on Customer {
      id
      name
    }
  }
}

A query like the one above ends up with an object that looks like this:

{
  "id": "...",
  "name": "..."
}

Since gqless works based on ES6 proxy it would have to find a unique way of supporting ... on Customer. This would end up as maybe some sort of nested property on the object, and gqless would have the task of translating the non-nested GQL response into a nested object. Take this example from above:

resolved(() => {
    const customer = query.node({ id: customerId }) as Customer
    const { __typename, id, name } = customer
    return { __typename, id, name}
})

This wouldn't really work because you can have multiple GQL interfaces with similar property names, and though gqless works with TS typings, at runtime it isn't aware of as Customer. So you may end up with something like this:

resolved(() => {
    const customer = query.node({ id: customerId })
    const { __typename, onCustomer: { id, name } } = customer
    return { __typename, id, name}
})

The above code might work, but would require your GQL API to not publish an onCustomer key, and it would require some convention in gqless to support it.

outerlook commented 3 years ago

Definitely.

What others API's could be used?

resolved(() => {
    const customer = query.node({ id: customerId })
    const { __typename, on: { Customer: {id, name} } } = customer
    return { __typename, id, name}
})

Could be another one that would reduce the "reserved word" count and become predictable.

Customer type would become Customer & {__typename: string}.

If there were multiple "on", it would have to Unionize the types based on the present ons

from example

query AllCharacters {
  all_characters {

    ... on Character {
      name
    }

    ... on Jedi {
      side
    }

    ... on Droid {
      model
    }
  }
}

would become

resolved(() => {
    const characters = query.all_characters
    const { on: { Character: {name}, Jedi: {side}, Droid: {model} } = characters[0]
    return characters
})
wjohnsto commented 3 years ago

To add another API example, here is a possible query for a Headless WordPress site that uses WPGraphQL:

{
  nodeByUri(uri: "/my-post-or-page") {
    id
    uri
    ... on ContentNode {
      slug
      ... on Post {
        title
        author {
          node {
            firstName
          }
        }
      }
    }
    ... on Post {
      title
    }
    ... on Page {
      title
    }
  }
}

In this example title is redundant on Post but is used to show how the API works. So you would have to support nested interfaces as well as conflicting property names (e.g. title as described above. Using the interface @outerlook suggests above would look something like this:

resolved(() => {
  const postOrPage = query.nodeByUri({ uri: "/my-post-or-page" });
  const {
    id,
    uri,
    on: {
      ContentNode: {
        slug,
        on: {
          Post: {
            title,
            author {
              node: {
                firstName
              }
            },
          }
        }
      },
      Post: {
        title
      },
      Page: {
        title
      }
    }
  } = postOrPage;
  return postOrPage;
});
vicary commented 3 years ago

$on: { ... } requires zero reserved words.

PabloSzx commented 3 years ago

the $on syntax makes sense 👍 this weekend I'm hoping to be able to start working on this and #178

wjohnsto commented 3 years ago

$on: { ... } requires zero reserved words.

Wow, the real solution is so simple. Call me an impostor!

wjohnsto commented 3 years ago

This could also be done as $on('NodeType').... I'm not sure what is easier/better to support.

PabloSzx commented 3 years ago

@wjohnsto @vicary @outerlook Can you test gqty@2.0.0 with the new "$on" syntax?

It's pretty much the same syntax as @wjohnsto mentioned, but with the property $on

resolved(() => {
  const postOrPage = query.nodeByUri({ uri: "/my-post-or-page" });
  const {
    id,
    uri,
    $on: {
      ContentNode: {
        slug,
        $on: {
          Post: {
            title,
            author {
              node: {
                firstName
              }
            },
          }
        }
      },
      Post: {
        title
      },
      Page: {
        title
      }
    }
  } = postOrPage;
  return postOrPage;
});
wjohnsto commented 3 years ago

I will be able to look at this before the end of the week 👍

blakewilson commented 3 years ago

The new $on functionality works well! With one caveat to the syntax:

In the above syntax, destructing is occurring with multiple instances of the same variable name. As in, the title is being destructed from both Post and Page. This will throw an error in TypeScript "Cannot redeclare block-scoped variable 'title'" and a runtime error.

This doesn't change the functionality, but will change how the docs will describe the usage I assume.

As an example, take the following code that destructures and renames the fields needed:

const postOrPage = useQuery().nodeByUri({ uri: "/my-post-or-page" });

const {
  $on: {
    Post: { title: postTitle, content: postContent },
    Page: { title: pageTitle, content: pageContent },
  },
} = postOrPage;

const title = postTitle || pageTitle;
const content = postContent || pageContent;