GraphQL for SQL Developers - Part 5

Relations and nullability

There's an old adage in relational databases that there are only three important numbers: 0, 1 and n. When modeling a system in SQL we are always thinking in terms of the nature of the relationships between tables. Is it one-to-many, many-to-one, many-to-many, or even one-to-one? Can there be zero of the many or does there have to be at least one?

Related to "zero" is also the concept of a null value. The corresponding concept in GraphQL is nullability. However in GraphQL nullability also applies to relationships in the sense that we can have one, zero, n, or null related values.

In part 1 we designed a GraphQL schema starting from a database schema and showed how relationships in GraphQL are usually modeled in both directions: from the one to the many and from the many to the one. Let's discuss this in a bit more detail and discuss how nullability impacts our schema definition.

We defined the Booking type as:

type Booking {
      id: ID!
      bookedAt: DateTime!
      reference: String!
      totalAmount: Float!
      tickets: [Ticket]
}

One-to-Many Relationships

All the scalar fields are marked as non-nullable with the exclamation mark. This guarantees that each of these will have a value when we retrieve a booking and ask for those fields. However there is no exclamation point next to [Ticket] so null is a possible return value.

To ensure that we always get an array of tickets we can make the reference to tickets in our Booking type non-nullable as well, e.g.:

type Booking {
      id: ID!
      …other fields
      tickets: [Ticket]!
}

This says that we will always get a list back for the tickets field. The list of tickets could possibly be empty. Furthermore we might have partial tickets in the array since we haven't mandated that the list contain tickets with all required fields. If we also add an exclamation point inside the square brackets, e.g.:

type Booking {
      id: ID!
      …other fields
      tickets: [Ticket!]!
}

Now each Ticket that is returned will include at least all the non-nullable fields we asked for.

A convenience of the Knex package is that a query that returns no records yields an empty list rather than null.

The benefit of this is not having to be as defensive in our client-side code when rendering a booking.

There is no easy way in GraphQL (that I know of) to specify that the list have at least one element or any other specific number.

Many-to-Many Relationships

Such relationships are quite common in databases. For the purpose of this post, let's distinguish between two flavors of many-to-many relationships:

  1. Simple: two tables are related via an intermediary table but the intermediary table carries no extra information beyond the foreign keys to the two tables.
  2. Rich: the intermediary table carries valuable extra information about the relationship itself.

Assume for a moment that the relationship between Tickets and Flights was simple. We could then have the two GraphQL types point directly to each other, e.g.:

type Ticket {
    …other fields
    flights: [Flight!]!
}

and

type Flight {
    …other fields
    tickets: [Ticket]!
}

From one Ticket I can get the related Flights and from one Flight I can get the related Tickets. Here I've indicated that a Ticket must have at least one related Flight but a Flight can have 0:n Tickets. (It's not unusual for a flight far in the future not to have any tickets sold for it yet).

But the relationship between tickets and flights is not simple. There's a fare condition, an amount, and possibly a boarding pass to be considered.

In part 3 we introduced the TicketedPassenger type to describe the rich relationship between tickets and flights. Our goal then was to create an itinerary based on a booking reference.

 type TicketedPassenger {
      amount: Float!
      fareConditions: String!
      boardingPass: BoardingPass
      ticket: Ticket!
}

A rich many-to-many relationship will typically require the creation of one or two new types.

Why is that? Remember that each field in a type can be one of three things:

  1. a scalar (number, string, date, etc…)
  2. another named type
  3. an array of (1) or (2)

Hence the TicketedPassenger type - we needed another named type to refer to from the flight.

What we didn't do in part 3 was to define a type for the inverse relationship between the Ticket and the Flight types. We can't use the TicketedPassenger in this case as is because it doesn't have any information about the flight. We therefore need to extend it with a flight field.

 type TicketedPassenger {
      amount: Float!
      fareConditions: String!
      boardingPass: BoardingPass
      flight: Flight!
      ticket: Ticket!
}

Then we can update our Ticket type accordingly. We name the field flights to avoid confusion with the existing passenger field.

Naming things is hard. GraphQL doesn't make naming things any easier.

type Ticket {
      id: ID!
      booking: Booking!
      flights: [TicketedPassenger!]!
      passenger: Passenger!
      ticketNo: String!
}

To have these relations actually work requires adding a flight resolver to the TicketedPassenger type and a flights resolver to the Ticket type in src/graphql/resolvers/typeResolvers/index.ts:

TicketedPassenger: {
     …other resolvers
    flight: ({ flightId }: tFlight) =>
      database('flights').where({ flightId }).select(['*']).first(),
 }

Ticket: {
    flights: ({ ticketNo }: tTicket) =>
      database('flights')
        .join('ticketFlights', 'flights.flightId', 'ticketFlights.flightId')
        .where({ ticketNo })
        .select(['flights.*'])
        .orderBy('scheduledDeparture'),
    …other resolvers
}

With these changes to our typeDefs and the new resolvers in place we can now run a query through from a booking to the flights involved.

query bookingToFlights($reference: String!){
  oneBooking(reference: $reference){
    id
    tickets {
      flights {
        flightNo
        scheduled {
          depart
        }
      }
    }
  }
}

Running the above against booking 3B54BB yields:

Screen Shot 2022-10-04 at 1.46.15 PM.png

Circular References in GraphQL

Since we have both forward and backward references in GraphQL it is easy to write a circular query. For example:

query circularQuery($reference: String!){
  oneBooking(reference: $reference){
    id
    tickets {
      booking {
        id
      }
    }
  }
}

We're asking for a booking and in that booking getting the tickets. At the ticket level we're turning around and asking for the booking information again! This is perfectly legitimate GraphQL even though it's not very useful. Your GraphQL server will run this without complaint, yielding:

Screen Shot 2022-10-07 at 4.43.22 PM.png

You can imagine this quickly getting out of hand if we went from one booking to all flights then back to all bookings for passengers on that flight then forward again to all flights for all the bookings. This turns out to be a pattern we need to defend against if we allow ad-hoc queries against our GraphQL server as they can be used in a denial-of-service attack. That is a topic for another day.