You are viewing the preview version of this book
Click here for the full version.

Queries and Mutations

Queries in GraphQL are based on two things: an entry point and traversals. The entry point is the initial object (or objects) that are coming from a Query or a Mutation type. This provides a "foothold" in the objects graph.

Then the query can define what other fields and objects it needs in the response. This makes GraphQL queries powerful: the client can define what it needs and it gets exactly that.

For example, a client might want to show the social graph for a user, so it fetches the friends:

query {
  user(id: "user1") {
    name
    friends {
      name
    }
  }
}

A different client might want to show an admin panel and show all the users with their email addresses and permissions:

query {
  allUsers {
    name
    email
    permissions {
      name
    }
  }
}

Both queries above use the same object graph, but they get different data. This gives GraphQL queries a lot of flexibility and if used well provides a faster user experience.

Note

It's all too easy to query everything and then select what is needed on the client-side. But this does not utilize the flexibility of the queries and goes against the best practices for GraphQL.

But queries are a lot more powerful than just selecting what fields the client needs. GraphQL provides a lot of features to fine-tune their behavior. In this chapter we'll look into the most useful ones and how to use them.

Fields

(Official docs)

What gives the most versatility for GraphQL queries is the ability to select the fields they need in the response. A type might have many fields, but if the client needs just a few of them, then it consumes less resources to omit the unneeded ones.

For example, a User object has a username, a bio, and an email address:

type User {
  username: String!
  email: String
  bio: String
}

type Query {
  user(username: String!): User
}

If the client does not need the email address, it can choose not to ask for that:

# query
query MyQuery {
  user(username: "user1") {
    username
    bio
  }
}

And the result:

{
  "data": {
    "user": {
      "username": "user1",
      "bio": "Lorem ipsum"
    }
  }
}
It's not just about the bandwidth

While most fields directly map to columns in a database, it is not always the case and the client should not assume it is "free" on the backend. For example, the User object in the database might only contain the username but not the email address. In a common scenario, returning the email means sending a call to Cognito (or a similar user directory) and extract the value from the response.

Because of this, clients should query only for data that they will actually use.

Fields can also be types defined in the schema. Getting a field that is not scalar is how a query can move from object to object in the graph. For example, showing the friends of a user means asking for the friend object and then their fields.

# schema
type User {
  username: String!
  email: String
  bio: String
  friends: [User!]!
}

type Query {
  user(username: String!): User
}
# query
query MyQuery {
  user(username: "user1") {
    username
    friends {
      username
    }
  }
}

And the result:

{
  "data": {
    "user": {
      "username": "user1",
      "friends": [
        {
          "username": "user2"
        },
        {
          "username": "user3"
        }
      ]
    }
  }
}

Arguments

(Official docs)

As we've seen in the Arguments chapter, fields of any type can define what arguments they need. These can be "top-level", such as a Query or a Mutation, but every field for every type can have arguments too.

A client query must provide all required arguments and it can fill any optional ones. When a query does not pass any arguments for a field the parentheses are missing.

For example, a query in the schema might define an optional argument and the client can choose whether to define that or not:

# schema
type Query {
  # No ! after String, so this is an optional argument
  allUsers(search: String): [User]
}

Both of these queries are valid:

query Query1 {
  allUsers {
    username
    email
  }
}

query Query2 {
  allUsers(search: "admin") {
    username
    email
  }
}

Arguments for fields work the same. Here, the username is required, while the after is optional:

# schema
type Ticket {
  text: String!
  # POSIX time
  created: Int!
}

type User {
  username: String!
  # the argument is optional
  tickets(after: Int): [Ticket]
}

type Query {
  # the argument is required
  user(username: String!): User
}

Defining arguments for a nested field works the same as for the query itself:

query Query1 {
  user(username: "user1") {
    username
    tickets(after: 1639907490) {
      text
      created
    }
  }
}

query Query2 {
  user(username: "user1") {
    username
    # no argument here
    tickets {
      text
      created
    }
  }
}

Variables

(Official docs)

As queries are text-based and arguments are usually user-supplied, for example a search field on a website, it's a bad idea to use string concatenation. Instead, GraphQL provides a way to separate the fixed part of the query from the variable part.

In this query, the variable part is the text argument of the search query:

query SearchQuery {
  search(text: "users") {
    text
    location
  }
}

With string concatenation it's all too easy to end up with something like this:

// don't do this

// search is the user-provided value
const graphQLQuery = `query SearchQuery {
  search(text: "${search}") {
    text
    location
  }
}`;

The problem here is that the value can contain characters that "break away" from the query argument. For example, if a user searches for something", then the resulting query is going to be:

query SearchQuery {
  search(text: "something"") {
    text
    location
  }
}

With the double ", the query is now invalid and the user will get an error.

To declare a variable for a query, give it a name and add it to the query object. Then whenever you want to define that value, use the variable name:

query SearchQuery($text: String) {
  search(text: $text) {
    text
    location
  }
}

Then pass the variable separately, which is dependent on the client library you are using.

For example, AppSync variables are in params/variables as a JSON object:

Variables are defined separately for an AWS AppSync query
Note

Unlike SQL injection, string concatenation in a GraphQL query is not a security vulnerability. This is because it happens on the client-side, so a malicious actor can already send any query they wish.

Except when the dynamic part is defined by a client different than the one sending the request, such as a Lambda function that gets parameters from a HTTP request. In that case, not using variables can open security vulnerabilities.

Aliases

(Official docs)

A query can define the same field multiple times, which is especially useful when you want to pass different arguments. For example, a user's tickets might be one of 3 states: OPEN, IN_PROGRESS, and DONE and you might want to return the top 3 tickets for each state.

# schema
enum STATUS {
  OPEN
  IN_PROGRESS
  DONE
}

type Ticket {
  id: ID!
  status: STATUS!
  description: String
}

type User {
  username: String!
  tickets(status: STATUS, limit: Int): [Ticket!]!
}

type Query {
  user(username: String!): User
}

The syntax is aliasname: field:

# query
query {
  user(id: "user1") {
    open_tickets: tickets(status: OPEN, limit: 3) {
      id
    }
    in_progress_tickets: tickets(
      status: IN_PROGRESS,
      limit: 3
    ) {
      id
    }
    done_tickets: tickets(status: DONE, limit: 3) {
      id
    }
  }
}

The result contains the tickets 3 times, but their names are open_tickets, in_progress_tickets, and done_tickets:

{
  "data": {
    "user": {
      "open_tickets": [
        {"id": "1"}, // ...
      ],
      "in_progress_tickets": [
        // ...
      ],
      "done_tickets": [
        // ...
      ]
    }
  }
}
Note

In the above example, what is the benefit of including the tickets 3 times, effectively making 3 database queries, instead of getting a bunch of tickets and sort them on the client-side?

Imagine there are a lot of new tickets coming to the system. If you want to show 3 IN_PROGRESS tickets, how many tickets should the client request? Maybe there are 1000 OPEN tickets, which means the client needs to send multiple (maybe a lot) of queries to get 3 that are not OPEN. Worse still, if the project is new and there are no IN_PROGRESS tickets (or less than three), the clients needs to fetch all tickets to determine what to show.

The above query makes it sure that the response contains only what the client needs.

Type safety

(Official docs)

As the schema defines what types a query can include and also the fields of each type, the GraphQL backend can do extensive validations even before it starts building the response. While it's not enough to catch all invalid data coming into the backend, it helps a lot both in security and accidental errors.

For example, queries with fields that are not in the schema raise a validation error, as well as missing or extra argument. Moreover, if the query passes an argument that is of a wrong type also triggers an error.

# schema
type User {
  id: ID
  name: String
}

type Query {
  user(id: ID!): User
}
# query
query Query {
  # Validation error of type FieldUndefined:
  # Field 'missing' in type 'User'
  # is undefined @ 'user/missing'
  user(id: "user1@example.com") {
    id
    name
    missing
  }
  # Validation error of type WrongType:
  # argument 'id' with value
  # 'BooleanValue{value=true}' is not
  # a valid 'ID' @ 'user'
  bad_arg: user(id: true) {
    id
  }
  # Validation error of type MissingFieldArgument:
  # Missing field argument id @ 'user'
  missing_arg: user {
    id
  }
}

Inline fragments

(Official docs)

Inline fragments help with queries that have unions or interfaces, since they don't define a single concrete type, but instead multiple type (unions) or an abstract type (interfaces). With inline fragments, the query can define what fields it needs for each concrete type.

Inline fragments for interfaces

For an interface, the system can have multiple user types with different fields:

# schema
interface User {
  username: String!
  email: String
}

type AdminUser implements User {
  username: String!
  email: String
  permissions: [String!]!
}

type NormalUser implements User {
  username: String!
  email: String
  nickname: String
}

type Query {
  allUsers: [User!]!
}

A query with an inline fragment can define what fields it needs if the type is an AdminUser and what types if it's NormalUser. The syntax is ... on <type> {fields}:

# query
query MyQuery {
  allUsers {
    username
    email
    ... on AdminUser {
      permissions
    }
    ... on NormalUser {
      nickname
    }
  }
}

The result won't have types, but the fields will be dependent on the user type and they contain all common fields (username and email) and also all fields for the type (either permissions or nickname):

{
  "data": {
    "allusers": [
      {
        "username": "user1",
        "email": "user1@example.com",
        "nickname": "Bob"
      },
      {
        "username": "admin1",
        "email": "admin2@example.com",
        "permissions": "system"
      }
    ]
  }
}

Inline fragments for union types

The same logic applies to unions too, as they also don't have a single type.

# schema
type User {
  username: String!
  email: String
}

type Ticket {
  text: String!
}

type Query {
  search(query: String!): User | Ticket
}

The same structure is used here as for the interfaces:

# query
query MyQuery {
  search(query: "test") {
    ... on User {
      username
      email
    }
    ... on Ticket {
      text
    }
  }
}

__typename

(Official docs)

The __typename is a meta field that a query can include for any type and it is the name of that type. It is most useful when a client needs to handle the result differently depending on the object type.

For example, the getCurrentUser can return a NormalUser or an AdminUser:

# schema
type AdminUser {
  username: String!
  email: String
}

type NormalUser {
  username: String!
  email: String
}

type Query {
  getCurrentUser: AdminUser | NormalUser
}

The query can include the __typename for the result:

# query
query MyQuery {
  allUsers {
    __typename
    username
    email
  }
}

And the response contains whether the user is an AdminUser and the client shows the admin control panel or a NormalUser and redirect to a landing page, for example:

{
  "data": {
    "getCurrentUser": {
      "username": "admin1",
      "email": "admin1@example.com",
      "__typename": "AdminUser"
    }
  }
}

In this example, the two types don't differ in their fields, so a client can't decide based on what properties are present in the response. But their __typename will always be different.

Note

While you could also include this information in the schema, for example in a user type:

type AdminUser {
  # ...
  admin: Boolean!
}

type NormalUser {
  # ...
  admin: Boolean!
}

The __typename meta field provides a cleaner alternative to this.

Since the __typename meta field is guaranteed to be present for all objects, it also provides a way for tools to generate strongly-types objects. For example, a TypeScript library can get the result JSON and return an object graph.

Mutations

(Official docs)

So far, all the examples was how to get data from a GraphQL API. Mutations, on the other hand, is how a client can change it, such as creating a new user, or closing a ticket in a ticketing system.

There is more, but you've reached the end of this preview
Read this and all other chapters in full and get lifetime access to:
  • all future updates
  • full web-based access
  • PDF and Epub versions