Documentation
Fundamentals
Relationship

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 Parts, 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

Resolver.swift
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.

Last updated on July 13, 2023