Adding Designers¶
So far, we’ve been working with just a single object type, BoardGame.
Let’s see what we can do when we add the Designer object type to the mix.
Initially, we’ll define each Designer in terms of an id, a name, and an optional home page URL.
{:games
[{:id "1234"
:name "Zertz"
:summary "Two player abstract with forced moves and shrinking board"
:designers #{"200"}
:minPlayers 2
:maxPlayers 2}
{:id "1235"
:name "Dominion"
:summary "Created the deck-building genre; zillions of expansions"
:designers #{"204"}
:minPlayers 2}
{:id "1236"
:name "Tiny Epic Galaxies"
:summary "Fast dice-based sci-fi space game with a bit of chaos"
:designers #{"203"}
:minPlayers 1
:maxPlayers 4}
{:id "1237"
:name "7 Wonders: Duel"
:summary "Tense, quick card game of developing civilizations"
:designers #{"201" "202"}
:minPlayers 2
:maxPlayers 2}]
:designers
[{:id "200"
:name "Kris Burm"
:url "http://www.gipf.com/project_gipf/burm/burm.html"}
{:id "201"
:name "Antoine Bauza"
:url "http://www.antoinebauza.fr/"}
{:id "202"
:name "Bruno Cathala"
:url "http://www.brunocathala.com/"}
{:id "203"
:name "Scott Almes"}
{:id "204"
:name "Donald X. Vaccarino"}]}
If this was a relational database, we’d likely have a join table between BoardGame and Designer, but that can come later. For now, we have a set of designer ids inside each BoardGame.
Schema Changes¶
{:objects
{:BoardGame
{:description "A physical or virtual board game."
:fields
{:id {:type (non-null ID)}
:name {:type (non-null String)}
:summary {:type String
:description "A one-line summary of the game."}
:description {:type String
:description "A long-form description of the game."}
:designers {:type (non-null (list :Designer))
:description "Designers who contributed to the game."}
:minPlayers {:type Int
:description "The minimum number of players the game supports."}
:maxPlayers {:type Int
:description "The maximum number of players the game supports."}
:playTime {:type Int
:description "Play time, in minutes, for a typical game."}}}
:Designer
{:description "A person who may have contributed to a board game design."
:fields
{:id {:type (non-null ID)}
:name {:type (non-null String)}
:url {:type String
:description "Home page URL, if known."}
:games {:type (non-null (list :BoardGame))
:description "Games designed by this designer."}}}
:Query
{:fields
{:gameById
{:type :BoardGame
:description "Access a BoardGame by its unique id, if it exists."
:args
{:id {:type ID}}}}}}}
We’ve added a designers
field to BoardGame, and added
a new Designer type.
In Lacinia, we use a wrapper, list
, around a type, to denote a list of that type.
In the EDN, the list
wrapper is applied using the syntax of a function call in Clojure code.
A second wrapper, non-null
, is used when a value must be present, and not null (or nil
in Clojure).
By default, all values can be nil and that flexibility is encouraged, so non-null
is rarely used.
Here we’ve defined the designers
field as (non-null (list :Designer))
.
This is somewhat overkill (the world won’t end if the result map contains a nil
instead of an
empty list), but demonstrates that the list
and non-null
modifiers can
nest properly.
We could go further: (non-null (list (non-null :Designer)))
… but that’s
adding far more complexity than value.
We need a field resolver for the designers
field, to convert from
what’s in our data (a set of designer ids) into what we are promising in the schema:
a list of Designer objects.
Likewise, we need a field resolver in the Designer entity to figure out which BoardGames are associated with the designer.
Code Changes¶
(ns my.clojure-game-geek.schema
"Contains custom resolvers and a function to provide the full schema."
(:require [clojure.java.io :as io]
[com.walmartlabs.lacinia.util :as util]
[com.walmartlabs.lacinia.schema :as schema]
[clojure.edn :as edn]))
(defn resolve-game-by-id
[games-map context args value]
(let [{:keys [id]} args]
(get games-map id)))
(defn resolve-board-game-designers
[designers-map context args board-game]
(->> board-game
:designers
(map designers-map)))
(defn resolve-designer-games
[games-map context args designer]
(let [{:keys [id]} designer]
(->> games-map
vals
(filter #(-> % :designers (contains? id))))))
(defn entity-map
[data k]
(reduce #(assoc %1 (:id %2) %2)
{}
(get data k)))
(defn resolver-map
[]
(let [cgg-data (-> (io/resource "cgg-data.edn")
slurp
edn/read-string)
games-map (entity-map cgg-data :games)
designers-map (entity-map cgg-data :designers)]
{:Query/gameById (partial resolve-game-by-id games-map)
:BoardGame/designers (partial resolve-board-game-designers designers-map)
:Designer/games (partial resolve-designer-games games-map)}))
(defn load-schema
[]
(-> (io/resource "cgg-schema.edn")
slurp
edn/read-string
(util/inject-resolvers (resolver-map))
schema/compile))
As with all field resolvers [1], resolve-board-game-designers
is passed the containing resolved value
(a BoardGame map, in this case)
and in turn, resolves the next step down, in this case, a list of Designers.
This is an important point: the data from your external source does not have to be in the shape described by your schema … you just must be able to transform it into that shape. Field resolvers come into play both when you need to fetch data from an external source, and when you need to reshape that data to match the schema.
For example, in our BoardGame values, the :designers
key is a set of designer ids, but in
our schema the BoardGame designers
field is a list of Designer objects.
The resolve-board-game-designers
resolver function dynamically reshapes the BoardGame value’s data into the
shape mandated by the GraphQL schema.
GraphQL doesn’t make any guarantees about order of values in a list field; when it matters, it falls on us to add documentation to describe the order, or even provide field arguments to let the client specify the order.
The inverse of resolve-board-game-designers
is resolve-designer-games
.
It starts with a Designer and uses the Designer’s id as a filter to find
BoardGames whose :designers
set contains the id.
Testing It Out¶
After reloading code in the REPL, we can exercise these new types and relationships:
(q "{ gameById(id: \"1237\") { name designers { name }}}")
=> {:data {:gameById {:name "7 Wonders: Duel",
:designers [{:name "Antoine Bauza"}
{:name "Bruno Cathala"}]}}}
For the first time, we’re seeing the “graph” in GraphQL.
An important part of GraphQL is that your query must always extend to scalar fields;
if you select a field that is a compound type, such as BoardGame/designers
, Lacinia will report an error instead:
(q "{ gameById(id: \"1237\") { name designers }}")
=>
{:errors [{:message "Field `designers' (of type `Designer') must have at least one selection.",
:locations [{:line 1, :column 25}]}]}
Notice how the :data
key is not present here … that indicates that the error
occured during the parse and prepare phases, before execution in earnest began.
To really demonstrate navigation, we can go from BoardGame to Designer and back:
(q "{ gameById(id: \"1234\") { name designers { name games { name }}}}")
=> {:data {:gameById {:name "Zertz",
:designers [{:name "Kris Burm",
:games [{:name "Zertz"}]}]}}}
Summary¶
Lacinia provides the mechanism to create relationships between entities, such as between BoardGame and Designer. It still falls on the field resolvers to provide that data for such linkages.
With that in place, the same com.walmartlabs.lacinia/execute function that gives us data about a single entity can traverse the graph and return data from a variety of entities, organized however you need it.
Next up, we’ll take what we have and make it easy to access via HTTP.
[1] | Root resolvers, such as for the gameById query operation, are the
exception: they are passed a nil value. |