Relationship
Relationship are integral part of GraphQL. It define how one entity integrate with another.
Relationship Resolver
Relationship field (fields that are referencing other types) can be done using a relationship resolver, which is similar to any field resolver.
Say we have a type Car
that have many Part
s, where each Part
holds the id
for the Car
it is for.
import struct Pioneer.ID
struct Car: Identifiable {
var id: ID
var model: String
}
struct Part: Identifiable {
var id: ID
var name: String
var carId: Car.ID
}
Resolver on Object type
Using extensions, we can describe a custom resolver function to fetch the Car
for a given Part
, and getting all the Part
for a given Car
.
extension Car {
func parts(ctx: Context, _: NoArguments) async throws -> [Part] {
try await ctx.db.find(Part.self).filter { $0.carId == id }
}
}
extension Part {
func car(ctx: Context, _: NoArguments) async throws -> User? {
try await ctx.db.find(Car.self).first { $0.id == carId }
}
}
In a real producation application, this example resolvers are flawed with the N+1 problem.
And update the schema accordingly.
type Car {
id: ID!
model: String!
parts: [Part!]!
}
type Part {
id: ID!
name: String!
car: Car
}
Schema in Graphiti (opens in a new tab)
This is an example how it would look like in Graphiti (opens in a new tab), this part is not restricted only to Graphiti (opens in a new tab).
import Graphiti
import Pioneer
func schema() throws -> Schema<Resolver, Context> {
try .init {
ID.asScalar()
Type(Car.self) {
Field("id", at: \.id)
Field("model", at: \.model)
Field("parts", at: Car.parts, as: [Part].self)
}
Type(Part.self) {
Field("id", at: \.id)
Field("name", at: \.name)
Field("car", at: Part.car, as: Car?.self)
}
}
}
N+1 Problem
Imagine your graph has query that lists items
query {
parts {
name
car {
id
model
}
}
}
with the parts
resolver looked like
struct Resolver {
func parts(ctx: Context, _: NoArguments) async throws -> [Part] {
try await ctx.db.find(Part.self)
}
}
The graph will executed that Resolver.parts
function which will make a request to the database to get all items.
Let's assume the database is a SQL database and the following SQL statements created when resolving the query are:
SELECT * FROM parts
SELECT * FROM cars WHERE id = ?
SELECT * FROM cars WHERE id = ?
SELECT * FROM cars WHERE id = ?
SELECT * FROM cars WHERE id = ?
SELECT * FROM cars WHERE id = ?
...
What's worse is that certain parts can be for the same car so these statements will likely query for the same users multiple times.
This is what's called the N+1 problem which you want to avoid. The solution? DataLoader.
DataLoader
The GraphQL Foundation provided a specification for solution to the N+1 problem called dataloader
. Essentially, dataloaders combine the fetching of process across all resolvers for a given GraphQL request into a single query.
The package Dataloader (opens in a new tab) implement that solution for GraphQLSwift/GraphQL (opens in a new tab).
.package(url: "https://github.com/GraphQLSwift/DataLoader", from: "...")
After that, we can create a function to build a new dataloader for each operation, and update the relationship resolver to use the loader
struct Context {
var db: Database
var carLoader: DataLoader<Car.ID, Car?>
var partsLoader: DataLoader<Car.ID, [Part]>
}
extension Car {
static func loader(ev: EventLoop, db: Database) -> DataLoader<Car.ID, Car?> {
.init(on: ev) { keys in
let cars = try? await db.find(Car.self).filter { keys.contains($0.id) }
return keys.map { key in
guard let car = cars?.first(where: { $0.id == key }) else {
return .succes(nil)
}
return .success(car)
}
}
}
}
extension Part {
static func loader(ev: EventLoop, db: Database) -> DataLoader<Car.ID, [Part]> {
.init(on: ev) { keys in
let all = try? await db.find(Part.self).filter { keys.contains($0.carId) }
return keys.map { key in
guard let parts = all?.filter({ $0.carId == key }) else {
return .success([])
}
return .success(parts)
}
}
}
}
It's best to create a loader for each operation as its cache will be valid only for that operation and doesn't create a out-of-sync cache problem on subsequent operations.
Dataloader (opens in a new tab) have a method called .loadMany
which takes multiple keys and return them all.
Using dataloader in resolvers
extension Car {
func parts(ctx: Context, _: NoArguments) async throws -> [Part] {
try await ctx.partsLoader.load(key: id, on: ev)
}
}
extension Part {
func car(ctx: Context, _: NoArguments, ev: EventLoopGroup) async throws -> User? {
try await ctx.carLoader.load(key: carId, on: ev)
}
}
Now instead of having n+1 queries to the database by using the dataloader, the only SQL queries sent to the database are:
SELECT * FROM parts
SELECT * FROM cars WHERE id IN (?, ?, ?, ?, ?, ...)
which is significantly better.