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

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 game’s 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"
   :min_players 2
   :max_players 2
   :designers #{"200"}}
  {:id "1235"
   :name "Dominion"
   :summary "Created the deck-building genre; zillions of expansions"
   :designers #{"204"}
   :min_players 2}
  {:id "1236"
   :name "Tiny Epic Galaxies"
   :summary "Fast dice-based sci-fi space game with a bit of chaos"
   :designers #{"203"}
   :min_players 1
   :max_players 4}
  {:id "1237"
   :name "7 Wonders: Duel"
   :summary "Tense, quick card game of developing civilizations"
   :designers #{"201" "202"}
   :min_players 2
   :max_players 2}]

 :members
 [{:id "37"
   :member_name "curiousattemptbunny"}
  {:id "1410"
   :member_name "bleedingedge"}
  {:id "2812"
   :member_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/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.stuartsierra.component :as component]
    [clojure.edn :as edn]))

(defn resolve-element-by-id
  [element-map 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
  [cgg-data]
  (fn [_ _ board-game]
    (let [id (:id board-game)
          ratings (->> cgg-data
                       :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
  [component]
  (let [cgg-data (-> (io/resource "cgg-data.edn")
                     slurp
                     edn/read-string)
        games-map (entity-map cgg-data :games)
        members-map (entity-map cgg-data :members)
        designers-map (entity-map cgg-data :designers)]
    {:query/game-by-id (partial resolve-element-by-id games-map)
     :query/member-by-id (partial resolve-element-by-id members-map)
     :BoardGame/designers (partial resolve-board-game-designers designers-map)
     :BoardGame/rating-summary (rating-summary cgg-data)
     :GameRating/game (game-rating->game games-map)
     :Designer/games (partial resolve-designer-games games-map)
     :Member/ratings (member-ratings (:ratings cgg-data))}))

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

We’ve generalized resolve-game-by-id into resolve-element-by-id.

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

These new resolvers are implemented using a new pattern. The existing resolvers, such as resolve-designer-games, took an initial parameter (a slice of the in-memory database), plus the standard triumvirate of context, field arguments, and container value. This approach is concise, but requires the use of partial (to supply that initial parameter) when building the resolvers map.

These new resolvers use a factory pattern instead: the extra value (the database map) is the only parameter, which is captured in a closure; a stand-alone field resolver function, one that accepts exactly the standard triumvirate, is returned. No use of partial needed anymore.

Further, 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 rating-summary 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. First, select the rating summary data for a game:

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

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

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