Game Rating Mutation

We’re finally ready to add our first mutation, which will be used to add a GameRating.

Our goal is a mutation which allows a member of ClojureGameGeek to apply a rating to a game. We must cover two cases: one where the member is adding an entirely new rating, and one where the member is revising a prior rating.

Along the way, we’ll also start to see how to handle errors, which tend to be more common when implementing mutations than with queries.

It is implicit that queries are idempotent (can be repeated getting the same results, and don’t change server-side state), whereas mutations are expected to make changes to server-side state as a side-effect. However, that side-effect is essentially invisible to Lacinia, as it will occur during the execution of a field resolver function.

The difference between a query and a mutation in GraphQL is razor thin. When the incoming query document contains only a single top level operation, as is the case in all the examples so far in this tutorial, then there is no difference at all between them.

When the query document contains multiple mutations, then the top-level mutations execute sequentially; the first completes before the second begins execution. For queries, execution order is not in a specified order (though the order of keys and values is specified by the client query).

We’ll consider the changes here back-to-front, starting with our database (which is still just a map inside an Atom).

Database Layer Changes

src/clojure_game_geek/db.clj
(ns clojure-game-geek.db
  (:require
    [clojure.edn :as edn]
    [clojure.java.io :as io]
    [com.stuartsierra.component :as component]))

(defrecord ClojureGameGeekDb [data]

  component/Lifecycle

  (start [this]
    (assoc this :data (-> (io/resource "cgg-data.edn")
                          slurp
                          edn/read-string
                          atom)))

  (stop [this]
    (assoc this :data nil)))

(defn new-db
  []
  {:db (map->ClojureGameGeekDb {})})

(defn find-game-by-id
  [db game-id]
  (->> db
       :data
       deref
       :games
       (filter #(= game-id (:id %)))
       first))

(defn find-member-by-id
  [db member-id]
  (->> db
       :data
       deref
       :members
       (filter #(= member-id (:id %)))
       first))

(defn list-designers-for-game
  [db game-id]
  (let [designers (:designers (find-game-by-id db game-id))]
    (->> db
         :data
         deref
         :designers
         (filter #(contains? designers (:id %))))))

(defn list-games-for-designer
  [db designer-id]
  (->> db
       :data
       deref
       :games
       (filter #(-> % :designers (contains? designer-id)))))

(defn list-ratings-for-game
  [db game-id]
  (->> db
       :data
       deref
       :ratings
       (filter #(= game-id (:game_id %)))))

(defn list-ratings-for-member
  [db member-id]
  (->> db
       :data
       deref
       :ratings
       (filter #(= member-id (:member_id %)))))

(defn ^:private apply-game-rating
  [game-ratings game-id member-id rating]
  (->> game-ratings
       (remove #(and (= game-id (:game_id %))
                     (= member-id (:member_id %))))
       (cons {:game_id game-id
              :member_id member-id
              :rating rating})))

(defn upsert-game-rating
  "Adds a new game rating, or changes the value of an existing game rating."
  [db game-id member-id rating]
  (-> db
      :data
      (swap! update :ratings apply-game-rating game-id member-id rating)))

Now, our goal here is not efficiency, it’s to provide clear and concise code. Efficiency comes later.

To that goal, the meat of the upsert, the apply-game-rating function, simply removes any prior row, and then adds a new row with the provided rating value.

Schema Changes

Our only change to the schema is to introduce the new mutation.

resources/cgg-schema.edn
{:objects
 {:BoardGame
  {:description "A physical or virtual board game."
   :fields
   {:id {:type (non-null ID)}
    :name {:type (non-null String)}
    :rating_summary {:type (non-null :GameRatingSummary)
                     :resolve :BoardGame/rating-summary}
    :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."
                :resolve :BoardGame/designers}
    :min_players {:type Int
                  :description "The minimum number of players the game supports."}
    :max_players {:type Int
                  :description "The maximum number of players the game supports."}
    :play_time {:type Int
                :description "Play time, in minutes, for a typical game."}}}

  :GameRatingSummary
  {:description "Summary of ratings for a single game."
   :fields
   {:count {:type (non-null Int)
            :description "Number of ratings provided for the game.  Ratings are 1 to 5 stars."}
    :average {:type (non-null Float)
              :description "The average value of all ratings, or 0 if never rated."}}}

  :Member
  {:description "A member of Clojure Game Geek.  Members can rate games."
   :fields
   {:id {:type (non-null ID)}
    :member_name {:type (non-null String)
                  :description "Unique name of member."}
    :ratings {:type (list :GameRating)
              :description "List of games and ratings provided by this member."
              :resolve :Member/ratings}}}

  :GameRating
  {:description "A member's rating of a particular game."
   :fields
   {:game {:type (non-null :BoardGame)
           :description "The Game rated by the member."
           :resolve :GameRating/game}
    :rating {:type (non-null Int)
             :description "The rating as 1 to 5 stars."}}}

  :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."
            :resolve :Designer/games}}}}

 :queries
 {:game_by_id
  {:type :BoardGame
   :description "Select a BoardGame by its unique id, if it exists."
   :args
   {:id {:type (non-null ID)}}
   :resolve :query/game-by-id}

  :member_by_id
  {:type :Member
   :description "Select a ClojureGameGeek Member by their unique id, if it exists."
   :args
   {:id {:type (non-null ID)}}
   :resolve :query/member-by-id}}

 :mutations
 {:rate_game
  {:type :BoardGame
   :description "Establishes a rating of a board game, by a Member.

   On success (the game and member both exist), selects the BoardGame.
   Otherwise, selects nil and an error."
   :args
   {:game_id {:type (non-null ID)}
    :member_id {:type (non-null ID)}
    :rating {:type (non-null Int)
             :description "Game rating as a number between 1 and 5."}}
   :resolve :mutation/rate-game}}}

Mutations nearly always include field arguments to define what will be affected by the mutation, and how. Here we have to provide field arguments to identify the game, the member, and the new rating.

Just as with queries, it is necessary to define what value will be resolved by the mutation; typically, when a mutation modifies a single object, that object is resolved.

Here, resolving a GameRating didn’t seem to provide value, and we arbitrarily decided to instead resolve the BoardGame … we could have just as easily resolved the Member instead. The right option is often revealed based on client requirements.

GraphQL doesn’t have a way to describe error cases comparable to how it defines types: every field resolver may return errors instead of, or in addition to, an actual value. We attempt to document the kinds of errors that may occur as part of the operation’s documentation.

Code Changes

Finally, we knit together the schema changes and the database changes in the schema namespace.

src/clojure_game_geek/schema.clj
(ns 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]
    [com.walmartlabs.lacinia.resolve :refer [resolve-as]]
    [com.stuartsierra.component :as component]
    [clojure-game-geek.db :as db]
    [clojure.edn :as edn]))

(defn game-by-id
  [db]
  (fn [_ args _]
    (db/find-game-by-id db (:id args))))

(defn member-by-id
  [db]
  (fn [_ args _]
    (db/find-member-by-id db (:id args))))

(defn rate-game
  [db]
  (fn [_ args _]
    (let [{game-id :game_id
           member-id :member_id
           rating :rating} args
          game (db/find-game-by-id db game-id)
          member (db/find-member-by-id db member-id)]
      (cond
        (nil? game)
        (resolve-as nil {:message "Game not found."
                         :status 404})

        (nil? member)
        (resolve-as nil {:message "Member not found."
                         :status 404})

        (not (<= 1 rating 5))
        (resolve-as nil {:message "Rating must be between 1 and 5."
                         :status 400})

        :else
        (do
          (db/upsert-game-rating db game-id member-id rating)
          game)))))

(defn board-game-designers
  [db]
  (fn [_ _ board-game]
    (db/list-designers-for-game db (:id board-game))))

(defn designer-games
  [db]
  (fn [_ _ designer]
    (db/list-games-for-designer db (:id designer))))

(defn rating-summary
  [db]
  (fn [_ _ board-game]
    (let [ratings (map :rating (db/list-ratings-for-game db (:id board-game)))
          n (count ratings)]
      {:count n
       :average (if (zero? n)
                  0
                  (/ (apply + ratings)
                     (float n)))})))

(defn member-ratings
  [db]
  (fn [_ _ member]
    (db/list-ratings-for-member db (:id member))))

(defn game-rating->game
  [db]
  (fn [_ _ game-rating]
    (db/find-game-by-id db (:game_id game-rating))))

(defn resolver-map
  [component]
  (let [db (:db component)]
    {:query/game-by-id (game-by-id db)
     :query/member-by-id (member-by-id db)
     :mutation/rate-game (rate-game db)
     :BoardGame/designers (board-game-designers db)
     :BoardGame/rating-summary (rating-summary db)
     :GameRating/game (game-rating->game db)
     :Designer/games (designer-games db)
     :Member/ratings (member-ratings db)}))

(defn load-schema
  [component]
  (-> (io/resource "cgg-schema.edn")
      slurp
      edn/read-string
      (util/attach-resolvers (resolver-map component))
      schema/compile))

(defrecord SchemaProvider [schema]

  component/Lifecycle

  (start [this]
    (assoc this :schema (load-schema this)))

  (stop [this]
    (assoc this :schema nil)))

(defn new-schema-provider
  []
  {:schema-provider (-> {}
                        map->SchemaProvider
                        (component/using [:db]))})

It all comes together in the rate-game function; we first check that the game_id and member_id passed in are valid (that is, they map to actual BoardGames and Members).

The resolve-as function is essential here: the first parameter is the value to resolve and is often nil when there are errors. The second parameter is an error map. [1]

resolve-as returns a wrapper object around the resolved value (which is nil in these examples) and the error map. Lacinia will later pull out the error map, add additional details, and add it to the :errors key of the result map.

These examples also show the use of the :status key in the error map. lacinia-pedestal will look for such values in the result map, and will set the HTTP status of the response to any value it finds (if there’s more than one, the HTTP status will be the maximum). The :status keys are stripped out of the error maps before the response is sent to the client.

At the REPL

Let’s start by seeing the initial state of things, using the default database:

(q "{ member_by_id(id: \"1410\") { member_name ratings { game { id name } rating }}}")
=>
{:data {:member_by_id {:member_name "bleedingedge",
                       :ratings [{:game {:id "1234", :name "Zertz"}, :rating 5}
                                 {:game {:id "1236", :name "Tiny Epic Galaxies"}, :rating 4}
                                 {:game {:id "1237", :name "7 Wonders: Duel"}, :rating 4}]}}}

Ok, so maybe we’ve soured on Tiny Epic Galaxies for the moment:

(q "mutation { rate_game(member_id: \"1410\", game_id: \"1236\", rating: 3) { rating_summary { count average }}}")
=> {:data {:rate_game {:rating_summary {:count 1, :average 3.0}}}}

(q "{ member_by_id(id: \"1410\") { member_name ratings { game { id name } rating }}}")
=>
{:data {:member_by_id {:member_name "bleedingedge",
                       :ratings [{:game {:id "1236", :name "Tiny Epic Galaxies"}, :rating 3}
                                 {:game {:id "1234", :name "Zertz"}, :rating 5}
                                 {:game {:id "1237", :name "7 Wonders: Duel"}, :rating 4}]}}}

Dominion is a personal favorite, so let’s rate that:

(q "mutation { rate_game(member_id: \"1410\", game_id: \"1235\", rating: 4) { name rating_summary { count average }}}")
=> {:data {:rate_game {:name "Dominion", :rating_summary {:count 1, :average 4.0}}}}

We can also see what happens when the query contains mistakes [2]:

(q "mutation { rate_game(member_id: \"1410\", game_id: \"9999\", rating: 4) { name rating_summary { count average }}}")
=>
{:data {:rate_game nil},
 :errors [{:message "Game not found.",
           :status 404,
           :locations [{:line 1, :column 9}],
           :query-path [:rate_game],
           :arguments {:member_id "1410", :game_id "9999", :rating "4"}}]}

Although the rate-game field resolver just returned a simple map (with keys :message and :status), Lacinia has enhanced the map identifying the location (within the query document), the query path (which indicates which operation or nested field was involved), and the arguments passed to the field resolver function.

In Lacinia, there’s a difference between a resolver error, from using resolve-as, and an overall failure parsing or executing the query. If the rating argument is omitted from the query, we can see a significant difference:

(q "mutation { rate_game(member_id: \"1410\", game_id: \"9999\") { name rating_summary { count average }}}")
=>
{:errors [{:message "Exception applying arguments to field `rate_game': Not all non-nullable arguments have supplied values.",
           :query-path [],
           :locations [{:line 1, :column 9}],
           :field :rate_game,
           :missing-arguments [:rating]}]}

Here, the result map contains only the :errors key; the :data key is missing. A similar error would occur if the type of value provided to field argument is unacceptible:

(q "mutation { rate_game(member_id: \"1410\", game_id: \"9999\", rating: \"Great!\") { name rating_summary { count average }}}")
=>
{:errors [{:message "Exception applying arguments to field `rate_game': For argument `rating', scalar value is not parsable as type `Int'.",
           :query-path [],
           :locations [{:line 1, :column 9}],
           :field :rate_game,
           :argument :rating,
           :value "Great!",
           :type-name :Int}]}
[1]Each map must contain, at a minimum, a :message key.
[2]In June 2018 update to the GraphQL specification, the format of error maps in the result map changed; when the tutorial upgrades to a version 0.29.0 of Lacinia, or later, the structure of the maps in the :errors key will change somewhat.