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 thannull
.
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:
- 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.
- 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:
- a scalar (number, string, date, etc…)
- another named type
- 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:
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:
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.