Adding Members and Ratings

We’re now starting an arc towards adding our first mutations.

We’re going to extend our schema to add Members (the name for a user of the Clojure Game Geek web site), and GameRatings … how a member has rated a game, on a scale of one to five.

Each Member can rate any BoardGame, but can only rate any single game once.

Schema Changes

First, let’s add new fields, types, and queries to support these new features:

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)}}}}}}}

For a particular BoardGame, you can get just a simple summary of the ratings: the total number, and a simple average.

We’ve added a new top-level entity, Member. From a Member, you can get a detailed list of all the games that member has rated.

Data Changes

We’ll model these ratings in our test data, much as we would a many-to-many relationship within a SQL database:

dev-resources/cgg-data.edn
{: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}]

 :members
 [{:id   "37"
   :name "curiousattemptbunny"}
  {:id   "1410"
   :name "bleedingedge"}
  {:id   "2812"
   :name "missyo"}]

 :ratings
 [{:member-id "37" :game-id "1234" :rating 3}
  {:member-id "1410" :game-id "1234" :rating 5}
  {:member-id "1410" :game-id "1236" :rating 4}
  {:member-id "1410" :game-id "1237" :rating 4}
  {:member-id "2812" :game-id "1237" :rating 4}
  {:member-id "37" :game-id "1237" :rating 5}]

 :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"}]}

New Resolvers

Our schema changes introduced a few new field resolvers, which we must implement:

src/my/clojure_game_geek/schema.clj
(ns my.clojure-game-geek.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require [clojure.java.io :as io]
            [com.stuartsierra.component :as component]
            [com.walmartlabs.lacinia.util :as util]
            [com.walmartlabs.lacinia.schema :as schema]
            [clojure.edn :as edn]))

(defn resolve-element-by-id
  [element-map]
  (fn [context args value]
    (let [{:keys [id]} args]
      (get element-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 rating-summary
  [ratings]
  (fn [_ _ board-game]
    (let [id (:id board-game)
          ratings' (->> ratings
                     (filter #(= id (:game-id %)))
                     (map :rating))
          n (count ratings')]
      {:count   n
       :average (if (zero? n)
                  0
                  (/ (apply + ratings')
                    (float n)))})))

(defn member-ratings
  [ratings-map]
  (fn [_ _ member]
    (let [id (:id member)]
      (filter #(= id (:member-id %)) ratings-map))))

(defn game-rating->game
  [games-map]
  (fn [_ _ game-rating]
    (get games-map (:game-id game-rating))))

(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)
        members-map (entity-map cgg-data :members)
        ratings (:ratings cgg-data)]
    {:Query/gameById          (resolve-element-by-id games-map)
     :Query/memberById        (resolve-element-by-id members-map)
     :BoardGame/designers     (partial resolve-board-game-designers designers-map)
     :BoardGame/ratingSummary (rating-summary ratings)
     :Designer/games          (partial resolve-designer-games games-map)
     :Member/ratings          (member-ratings ratings)
     :GameRating/game         (game-rating->game games-map)}))

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

(defrecord SchemaProvider [schema]

  component/Lifecycle

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

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

We’ve generalized resolve-game-by-id into resolve-element-by-id so that we could re-use the logic for the memberById query. This is another example of a higher order function, in that it is a function that is passed in a map and returns a new function that closes [1] on the provided element map (a map of BoardGames in one case, a map of Members in the other).

We’ve introduced three new resolvers, rating-summary, member-ratings, and game-rating->game.

These new resolvers are implemented using a the same style as resolve-element-by-id; each function acts as a factory, returning the actual field resolver function. No use of partial is needed anymore.

This new pattern is closer to what we’ll end up with in a later tutorial chapter, when we see how to use a Component as a field resolver.

It’s worth emphasising again that field resolvers don’t just access data, they can transform it. The ratingSummary field resolver is an example of that; there’s no database entity directly corresponding to the schema type :GameRatingSummary, but the field resolver can build that information directly. There doesn’t even have to be a special type or record … just a standard Clojure map with the correctly named keys.

Testing it Out

Back at the REPL, we can test out the new functionality. We need the server started after the component refactoring:

(start)
=> :started

First, select the rating summary data for a game:

(q "{ gameById(id: \"1237\") { name ratingSummary { count average }}}")
=> {:data {:gameById {:name "7 Wonders: Duel", :ratingSummary {:count 3, :average 4.333333333333333}}}}

We can also lookup a member, and find all the games they’ve rated:

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

In fact, leveraging the “graph” in GraphQL, we can compare a member’s ratings to the averages:

(q "{ memberById(id: \"1410\") { name ratings { game { name  ratingSummary { average }}  rating }}}")
=>
{:data {:memberById {:name "bleedingedge",
                     :ratings [{:game {:name "Zertz", :ratingSummary {:average 4.0}}, :rating 5}
                               {:game {:name "Tiny Epic Galaxies", :ratingSummary {:average 4.0}}, :rating 4}
                               {:game {:name "7 Wonders: Duel", :ratingSummary {:average 4.333333333333333}}, :rating 4}]}}}

Summary

We’re beginning to pick up the pace, working with our application’s simple skeleton to add new types and relationships to the queries.

Next up, we’ll add new components to manage an in-memory database.

[1]This is a computer science term that means that the value, element-map, will be in-scope inside the returned function after the resolve-element-by-id function returns; the returned function is a closure and, yes, that’s part of the basis for the name Clojure.