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 for particular game, and one where the member is revising a prior rating of the game.

Along the way, we’ll also start to see how to handle errors; errors 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 in how Lacinia executes a query and a mutation 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.

GraphQL allows a single request to contain multiple operations of the same type: multiple queries or multiple mutations.

When possible, Lacinia will execute multiple query operations in parallel [1]. Multiple mutations are always executed serially.

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/my/clojure_game_geek/db.clj
(ns my.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 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 - this is throw away code - 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)}
    :summary       {:type        String
                    :description "A one-line summary of the game."}
    :ratingSummary {:type        (non-null :GameRatingSummary)
                    :description "Summarizes member ratings for 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."}}}

  :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)}
    :name    {:type        (non-null String)
              :description "Unique name of the member."}
    :ratings {:type        (list :GameRating)
              :description "List of games and ratings provided by this member."}}}

  :GameRating
  {:description "A member's rating of a particular game."
   :fields
   {:game   {:type        (non-null :BoardGame)
             :description "The Game rated by the member."}
    :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."}}}

  :Query
  {:fields
   {:gameById
    {:type        :BoardGame
     :description "Access a BoardGame by its unique id, if it exists."
     :args
     {:id {:type ID}}}

    :memberById
    {:type        :Member
     :description "Access a ClojureGameGeek Member by their unique id, if it exists."
     :args
     {:id {:type (non-null ID)}}}}}

  :Mutation
  {:fields
   {:rateGame
    {:type :BoardGame
     :description "Establishes a rating of a board game, by a Member."
     :args
     {:gameId {:type (non-null ID)}
      :memberId {:type (non-null ID)}
      :rating {:type (non-null Int)
               :description "Game rating as number between 1 and 5."}}}}}}}

Mutation is another special GraphQL object, much like Query. It’s fields define what mutations are available in the schema.

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, in its updated state.

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/my/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 gameId and memberId 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. [2]

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. [3]

At the REPL

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

(q "{ memberById(id: \"1410\") { name ratings { game { id name } rating }}}")
=>
{:data {:memberById {: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 { rateGame(memberId: \"1410\", gameId: \"1236\", rating: 3) { ratingSummary { count average }}}")

=> {:data {:rateGame {:ratingSummary {:count 1, :average 3.0}}}}
(q "{ memberById(id: \"1410\") { name ratings { game { id name } rating }}}")

=>
{:data {:memberById {: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 { rateGame(memberId: \"1410\", gameId: \"1235\", rating: 4) { name ratingSummary { count average }}}")
=> {:data {:rateGame {:name "Dominion", :ratingSummary {:count 1, :average 4.0}}}}

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

(q "mutation { rateGame(memberId: \"1410\", gameId: \"9999\", rating: 4) { name ratingSummary { count average }}}")

=>
{:data {:rateGame nil},
 :errors [{:message "Game not found",
           :locations [{:line 1, :column 12}],
           :path [:rateGame],
           :extensions {:status 404, :arguments {:memberId "1410", :gameId "9999", :rating 4}}}]}

Although the rate-game field resolver just returned a simple error 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. It has also moved any keys it doesn’t recognize, in this case :status and :arguments, to an embedded :extensions map.

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 { rateGame(memberId: \"1410\", gameId: \"9999\") { name ratingSummary { count average }}}")

=>
{:errors [{:message "Exception applying arguments to field `rateGame': Not all non-nullable arguments have supplied values.",
           :locations [{:line 1, :column 12}],
           :extensions {:field-name :Mutation/rateGame, :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 incompatible:

(q "mutation { rateGame(memberId: \"1410\", gameId: \"9999\", rating: \"Great!\") { name rating_summary { count average }}}")

=>
{:errors [{:message "Exception applying arguments to field `rateGame': For argument `rating', unable to convert \"Great!\" to scalar type `Int'.",
           :locations [{:line 1, :column 12}],
           :extensions {:field-name :Mutation/rateGame,
                        :argument :Mutation/rateGame.rating,
                        :value "Great!",
                        :type-name :Int}}]}

Summary

And now we have mutations! The basic structure of our application is nearly fully formed, but we can’t go to production with an in-memory database. In the next chapter, we’ll start work on storing the database data in an actual SQL database.

[1]Parallel execution is optional in Lacinia, and requires application changes to support it.
[2]Each map must contain, at a minimum, a :message key.
[3]The very idea of changing the HTTP response status is somewhat antithetical to some GraphQL developers and this behavior is optional, but on by default.