Going through Lacinia tutorial to get familiar with GraphQL
This is my work through the very cool tutorial by Howard Lewis Ship.
Let's start!
The repo with the complete source is here:
git clone https://github.com/walmartlabs/clojure-game-geek
This is a project about board games and feedback. First the template project
lein new lacinia-tutorial
The project has to change slightly:
(defproject lacinia-tutorial "0.1.0-SNAPSHOT" :description "FIXME: write description" :url "http://example.com/FIXME" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :dependencies [[org.clojure/clojure "1.8.0"] [com.walmartlabs/lacinia "0.21.0"]])
Lacinia uses third party libraries to parse the GraphQL schemas and store in ordered maps.
Schemas have the edn format:
{:objects {:BoardGame {:description "A physical or virtual board game." :fields {:id {:type (non-null ID)} ;; ID is a GraphQL symbol and a Lacinia ;; base type, the non-null bit is a ;; Lacinia constraint: this is likely ;; treated as clojure.spec :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."} :min_players {:type Int ;; lacinia works only with underscores due to problems with clojure.spec :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."}}}} :queries {:game_by_id {:type :BoardGame :description "Access a BoardGame by its unique id, if it exists." :args {:id {:type ID}} :resolve :query/game-by-id}}} ; this is the GraphQL query resolver
Now we need an interpreter:
(ns lacinia-tutorial.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 resolver-map [] {:query/game-by-id (fn [context args value] ;; this is our resolver implementation nil)}) (defn load-schema [] (-> (io/resource "cgg-schema.edn") ;; load text from resource directory slurp edn/read-string ;; get the data as a clojure object (util/attach-resolvers (resolver-map)) ;; apply the resolver schema/compile)) ;; this should produce the GraphQL api?
For REPL development it can be useful to setup the user
namespace:
(ns user (:require [lacinia-tutorial.schema :as s] [com.walmartlabs.lacinia :as lacinia])) (def schema (s/load-schema)) (defn q [query-string] (lacinia/execute schema query-string nil nil)) ;; this is a quick ;; way to query our ;; GraphQL API ;; without starting ;; a server
This should now let us use the REPL:
(q "{ game_by_id(id: \"foo\") { id name summary }}")
Naturally this resolves to empty because the resolver is programmed so.
Let's now create some data that respect the schema:
{:games [{:id "1234" :name "Zertz" :summary "Two player abstract with forced moves and shrinking board" :min_players 2 :max_players 2 } {:id "1235" :name "Dominion" :summary "Created the deck-building genre; zillions of expansions" :min_players 2} {:id "1236" :name "Tiny Epic Galaxies" :summary "Fast dice-based sci-fi space game with a bit of chaos" :min_players 1 :max_players 4} {:id "1237" :name "7 Wonders: Duel" :summary "Tense, quick card game of developing civilizations" :min_players 2 :max_players 2}]}
And let's now use the data in the code:
(ns lacinia-tutorial.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] ; the last 3 attributes are provided by the resolver (let [{:keys [id]} args] (get games-map id))) (defn resolver-map [] (let [cgg-data (-> (io/resource "cgg-data.edn") slurp edn/read-string) games-map (->> cgg-data :games ; taking the games from the data (reduce #(assoc %1 (:id %2) %2) {}))] {:query/game-by-id (partial resolve-game-by-id games-map)})) (defn load-schema [] (-> (io/resource "cgg-schema.edn") ;; load text from resource directory slurp edn/read-string ;; get the data as a clojure object (util/attach-resolvers (resolver-map)) ;; apply the resolver by substituting the :query keywords with the functions schema/compile)) ;; this should produce the GraphQL api?
Let's fix up our development file to use Clojure maps:
(ns user (:require [lacinia-tutorial.schema :as s] [com.walmartlabs.lacinia :as lacinia] [clojure.walk :as walk]) (:import (clojure.lang IPersistentMap))) (def schema (s/load-schema)) (defn simplify "Converts all ordered maps nested within the map into standard hash maps, and sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems." [m] (walk/postwalk (fn [node] (cond (instance? IPersistentMap node) (into {} node) (seq? node) (vec node) :else node)) m)) (defn q [query-string] (-> (lacinia/execute schema query-string nil nil) simplify))
Now the query shows a normal map:
(use 'user :reload) ; to reload the user ns (q "{ game_by_id(id: \"anything\") { id name summary }}")
And we can also do a query according to the data we have:
(use 'user :reload-all) ; to reload all the ns (q "{ game_by_id(id: \"1237\") {name summary min_players max_players}}")
Let's now add a field in the data we got:
{:games [{:id "1234" :name "Zertz" :summary "Two player abstract with forced moves and shrinking board" :min_players 2 :max_players 2 :designers #{"200"}} ;; reference to the designer id. I think this is interpreted as a set of designer ids by the resolver {: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}] :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"}]}
By not doing anything the query on designers fails:
(use 'user :reload-all) ; to reload all the ns (q "{ game_by_id(id: \"1237\") {designers}}")
The reason is that our schema does not contain such data. No schema no party:
{:objects {:BoardGame {:description "A physical or virtual board game." :fields {:id {:type (non-null ID)} ;; ID is a GraphQL symbol and a Lacinia ;; base type, the non-null bit is a ;; Lacinia constraint: this is likely ;; treated as clojure.spec :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 ID)) :description "A long-form description of the game."} :min_players {:type Int ;; lacinia works only with underscores due to problems with clojure.spec :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."}}}} :queries {:game_by_id {:type :BoardGame :description "Access a BoardGame by its unique id, if it exists." :args {:id {:type ID}} :resolve :query/game-by-id}}} ; this is the GraphQL query resolver
Now the query works:
(use 'user :reload-all) ; to reload all the ns (q "{ game_by_id(id: \"1237\") {designers}}")
However we want to interpret the ids to a designer object. Let's change the schema again:
{: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." :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."}}} :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 "Access a BoardGame by its unique id, if it exists." :args {:id {:type ID}} :resolve :query/game-by-id}}}
Now we are missing a resolver!
(use 'user :reload-all) ; to reload all the ns (q "{ game_by_id(id: \"1237\") {designers}}")
Let's define the designer resolver:
(ns lacinia-tutorial.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] ; the last 3 attributes are provided by the resolver (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/game-by-id (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") ;; load text from resource directory slurp edn/read-string ;; get the data as a clojure object (util/attach-resolvers (resolver-map)) ;; apply the resolver schema/compile)) ;; this should produce the GraphQL api?
And now a nested query:
(use 'user :reload-all) ; to reload all the ns (q "{ game_by_id(id: \"1237\") { name designers { name }}}")
Now queries need to be nested otherwise we get an error:
(use 'user :reload-all) ; to reload all the ns (q "{ game_by_id(id: \"1237\") { name designers }}")
And since we have defined an isomorphism between the data (from designer to board and viz), we can show the graph side of this query language:
(use 'user :reload-all) ; to reload all the ns (q "{ game_by_id(id: \"1234\") { name designers { name games { name }}}}") ;; this query uses the isomorphism
Now that we have a working REPL, let's move on to the web interface:
(defproject clojure-game-geek "0.1.0-SNAPSHOT" :description "A tiny BoardGameGeek clone written in Clojure with Lacinia" :url "https://github.com/walmartlabs/clojure-game-geek" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :dependencies [[org.clojure/clojure "1.8.0"] [com.walmartlabs/lacinia-pedestal "0.5.0"] [io.aviso/logging "0.2.0"]])
lacinia-pedestal
is the web layer based on jetty.
We can now setup logging with a Logback library configuration file:
<configuration scan="true" scanPeriod="1 seconds"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%-5level %logger - %msg%n</pattern> </encoder> </appender> <root level="warn"> <appender-ref ref="STDOUT"/> </root> </configuration>
And now let's improve our REPL development tools:
(ns user (:require [lacinia-tutorial.schema :as s] [com.walmartlabs.lacinia :as lacinia] [com.walmartlabs.lacinia.pedestal :as lp] [io.pedestal.http :as http] [clojure.java.browse :refer [browse-url]] [clojure.walk :as walk]) (:import (clojure.lang IPersistentMap))) (def schema (s/load-schema)) (defn simplify "Converts all ordered maps nested within the map into standard hash maps, and sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems." [m] (walk/postwalk (fn [node] (cond (instance? IPersistentMap node) (into {} node) (seq? node) (vec node) :else node)) m)) (defn q [query-string] (-> (lacinia/execute schema query-string nil nil) simplify)) (defonce server nil) (defn start-server [_] (let [server (-> schema (lp/service-map {:graphiql true}) http/create-server http/start)] (browse-url "http://localhost:8888/") server)) (defn stop-server [server] (http/stop server) nil) (defn start [] (alter-var-root #'server start-server) :started) (defn stop [] (alter-var-root #'server stop-server) :stopped)
We have enabled graphiql to have at disposal the interactive REPL of GraphQL. This should not be enabled in PRD.
Now we can start the server:
(start)
The GraphIQL interface is cool: the Docs button is very useful to explore the schema available.
Let's handle state with the component library:
(defproject clojure-game-geek "0.1.0-SNAPSHOT" :description "A tiny BoardGameGeek clone written in Clojure with Lacinia" :url "https://github.com/walmartlabs/clojure-game-geek" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :dependencies [[org.clojure/clojure "1.8.0"] [com.walmartlabs/lacinia-pedestal "0.5.0"] [com.stuartsierra/component "0.3.2"] [io.aviso/logging "0.2.0"]])
We will have two component: the server and the schema provider. We know that the server depends on the schema provider (no schema no party XD).
The schema provider:
(ns lacinia-tutorial.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-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 [component] ; now this function depends on a component (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/game-by-id (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 [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))) ; a record can override methods (stop [this] (assoc this :schema nil))) (defn new-schema-provider ;; a constructor for the SchemaProvider [] {:schema-provider (map->SchemaProvider {})})
And the server component:
(ns lacinia-tutorial.server (:require [com.stuartsierra.component :as component] [com.walmartlabs.lacinia.pedestal :as lp] [io.pedestal.http :as http])) (defrecord Server [schema-provider server] ; this is what we had in the user.clj before component/Lifecycle (start [this] (assoc this :server (-> schema-provider :schema (lp/service-map {:graphiql true}) http/create-server http/start))) (stop [this] (http/stop server) (assoc this :server nil))) (defn new-server [] {:server (component/using (map->Server {}) ;; here the dependency on the :schema-provider component [:schema-provider])})
And to have a high level view of the components:
(ns lacinia-tutorial.system (:require [com.stuartsierra.component :as component] [lacinia-tutorial.schema :as schema] [lacinia-tutorial.server :as server])) (defn new-system [] (merge (component/system-map) (server/new-server) (schema/new-schema-provider)))
Finally the user.clj has to change:
(ns user (:require [com.walmartlabs.lacinia :as lacinia] [clojure.java.browse :refer [browse-url]] [lacinia-tutorial.system :as system] [clojure.walk :as walk] [com.stuartsierra.component :as component]) (:import (clojure.lang IPersistentMap))) (defn simplify "Converts all ordered maps nested within the map into standard hash maps, and sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems." [m] (walk/postwalk (fn [node] (cond (instance? IPersistentMap node) (into {} node) (seq? node) (vec node) :else node)) m)) (defonce system (system/new-system)) (defn q [query-string] (-> system ; here we are deconstructing our system :schema-provider :schema (lacinia/execute query-string nil nil) simplify)) (defn start [] (alter-var-root #'system component/start-system) ; here we change our system atom (browse-url "http://localhost:8888/") :started) (defn stop [] (alter-var-root #'system component/stop-system) :stopped)
And it works:
(start)
We are missing some information from our data schema that could be useful:
{: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) ; allow people to add rating of a game :resolve :BoardGame/rating-summary} ; this will use the :GameRating :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) ; so we cannot add constraints on the range of symbols? Weird :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}}} ; this will link members and 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} ; this will create an isomorphism :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}}}
As we are adding a required value our data has to change:
{: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"}]}
And so we change our schema.clj
(ns lacinia-tutorial.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) ;; isn't this becoming a litle to long? :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 {})})
Let's try:
(start) (q "{ game_by_id(id: \"1237\") { name rating_summary { count average }}}") (q "{ member_by_id(id: \"1410\") { member_name ratings { game { name } rating }}}")
The cool thing about GraphQL is that it allows to modify data as well! So far our resolvers were just reading data. In GraphQL a mutation allows to alter existing data. We will need to set up a mutable data structure: a database!
A database is another component: our schema provider will depend on it.
(ns lacinia-tutorial.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 %)))))
We essentially just embed the db in a component. Notice the :data atom: this is our mutable data strucutre.
Again the system will change:
(ns lacinia-tutorial.system (:require [com.stuartsierra.component :as component] [lacinia-tutorial.schema :as schema] [lacinia-tutorial.server :as server] [lacinia-tutorial.db :as db])) (defn new-system [] (merge (component/system-map) (server/new-server) (schema/new-schema-provider) (db/new-db)))
Now we have to enforce the dependency on the schema provider:
(ns lacinia-tutorial.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] [lacinia-tutorial.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 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) :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]))})
Now we can test our GraphQL again:
(start) (q "{ member_by_id(id: \"1410\") { member_name ratings { game { name rating_summary { count average } designers { name games { name }}} rating }}}")
All this setup for enabling mutations finally made us ready to change some rating data:
(ns lacinia-tutorial.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)))
Then let's make space in the schema for a mutation:
{: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) ; allow people to add rating of a game :resolve :BoardGame/rating-summary} ; this will use the :GameRating :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) ; so we cannot add constraints on the range of symbols? Weird :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}}} ; this will link members and 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} ; this will create an isomorphism :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 ; the 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." ; errors do not have a type in GraphQL!! The rationale is that any resolver can return errors. :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}}}}
Errors do not have a typep in GraphQL!! The rationale is that any resolver can return errors.
It remains to implement the mutation:
(ns lacinia-tutorial.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] [lacinia-tutorial.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." ; this are the errors :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 ; the success (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]))})
resolve-as
should return nil
with errors.
Let's try out our first mutation. We first read the data:
(start) (q "{ member_by_id(id: \"1410\") { member_name ratings { game { id name } rating }}}")
Then we modify the data:
(q "mutation { rate_game(member_id: \"1410\", game_id: \"1236\", rating: 3) { rating_summary { count average }}}")
And we check that the result was persisted:
(q "{ member_by_id(id: \"1410\") { member_name ratings { game { id name } rating }}}")
Also let's note the difference between an expected error
(q "mutation { rate_game(member_id: \"1410\", game_id: \"9999\", rating: 4) { name rating_summary { count average }}}")
and an unexpected one:
(q "mutation { rate_game(member_id: \"1410\", game_id: \"9999\") { name rating_summary { count average }}}")
After all of this mutating, we really should think about a serious database. Let's use PostgreSQL!
(defproject clojure-game-geek "0.1.0-SNAPSHOT" :description "A tiny BoardGameGeek clone written in Clojure with Lacinia" :url "https://github.com/walmartlabs/clojure-game-geek" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :dependencies [[org.clojure/clojure "1.9.0"] [com.stuartsierra/component "0.3.2"] [com.walmartlabs/lacinia "0.30.0"] [com.walmartlabs/lacinia-pedestal "0.10.0"] [org.clojure/java.jdbc "0.7.8"] [org.postgresql/postgresql "42.2.5.jre7"] [com.mchange/c3p0 "0.9.5.2"] [io.aviso/logging "0.3.1"]])
jdbc
is our wrapper to connect with database. c3p0
will care about
grouping our connections efficiently. postgresql
will provide a
wrapper to communicate with the db.
Let's setup the docker environment that will guest our DB:
version: '3' services: db: ports: - 25432:5432 image: postgres:10.2-alpine
A script to start docker will simplify matters:
#!/usr/bin/env bash docker-compose -p cgg up -d
And one to stop docker:
#!/usr/bin/env bash docker-compose -p cgg down
And another to start a sql shell from the container:
#!/usr/bin/env bash docker exec -ti --user postgres cgg_db_1 psql -Ucgg_role cggdb
And a final one to fill the DB within the container:
#!/usr/bin/env bash docker exec -i --user postgres cgg_db_1 createdb cggdb docker exec -i --user postgres cgg_db_1 psql cggdb -a <<__END create user cgg_role password 'lacinia'; __END docker exec -i cgg_db_1 psql -Ucgg_role cggdb -a <<__END drop table if exists designer_to_game; drop table if exists game_rating; drop table if exists member; drop table if exists board_game; drop table if exists designer; CREATE OR REPLACE FUNCTION mantain_updated_at() RETURNS TRIGGER AS \$\$ BEGIN NEW.updated_at = now(); RETURN NEW; END; \$\$ language 'plpgsql'; create table member ( member_id int generated by default as identity primary key, name text not null, created_at timestamp not null default current_timestamp, updated_at timestamp not null default current_timestamp); create trigger member_updated_at before update on member for each row execute procedure mantain_updated_at(); create table board_game ( game_id int generated by default as identity primary key, name text not null, summary text, min_players integer, max_players integer, created_at timestamp not null default current_timestamp, updated_at timestamp not null default current_timestamp); create trigger board_game_updated_at before update on board_game for each row execute procedure mantain_updated_at(); create table designer ( designer_id int generated by default as identity primary key, name text not null, uri text, created_at timestamp not null default current_timestamp, updated_at timestamp not null default current_timestamp); create trigger designer_updated_at before update on designer for each row execute procedure mantain_updated_at(); create table game_rating ( game_id int references board_game(game_id), member_id int references member(member_id), rating integer not null, created_at timestamp not null default current_timestamp, updated_at timestamp not null default current_timestamp); create trigger game_rating_updated_at before update on game_rating for each row execute procedure mantain_updated_at(); create table designer_to_game ( designer_id int references designer(designer_id), game_id int references board_game(game_id), primary key (designer_id, game_id)); insert into board_game (game_id, name, summary, min_players, max_players) values (1234, 'Zertz', 'Two player abstract with forced moves and shrinking board', 2, 2), (1235, 'Dominion', 'Created the deck-building genre; zillions of expansions', 2, null), (1236, 'Tiny Epic Galaxies', 'Fast dice-based sci-fi space game with a bit of chaos', 1, 4), (1237, '7 Wonders: Duel', 'Tense, quick card game of developing civilizations', 2, 2); alter table board_game alter column game_id restart with 1300; insert into member (member_id, name) values (37, 'curiousattemptbunny'), (1410, 'bleedingedge'), (2812, 'missyo'); alter table member alter column member_id restart with 2900; insert into designer (designer_id, name, uri) values (200, 'Kris Burm', 'http://www.gipf.com/project_gipf/burm/burm.html'), (201, 'Antoine Bauza', 'http://www.antoinebauza.fr/'), (202, 'Bruno Cathala', 'http://www.brunocathala.com/'), (203, 'Scott Almes', null), (204, 'Donald X. Vaccarino', null); alter table designer alter column designer_id restart with 300; insert into designer_to_game (designer_id, game_id) values (200, 1234), (201, 1237), (204, 1235), (203, 1236), (202, 1237); insert into game_rating (game_id, member_id, rating) values (1234, 37, 3), (1234, 1410, 5), (1236, 1410, 4), (1237, 1410, 4), (1237, 2812, 4), (1237, 37, 5); __END
With this final script we are moving towards a world of generated numeric identifiers. This means that from now on our schema is different:
{:objects {:BoardGame {:description "A physical or virtual board game." :fields {:game_id {:type (non-null Int)} :name {:type (non-null String)} :rating_summary {:type (non-null :GameRatingSummary) ; allow people to add rating of a game :resolve :BoardGame/rating-summary} ; this will use the :GameRating :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) ; so we cannot add constraints on the range of symbols? Weird :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 {:member_id {:type (non-null Int)} :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}}} ; this will link members and 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} ; this will create an isomorphism :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 {:designer_id {:type (non-null Int)} :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 Int)}} :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 Int)}} :resolve :query/member-by-id}} :mutations ; the 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." ; errors do not have a type in GraphQL!! The rationale is that any resolver can return errors. :args {:game_id {:type (non-null Int)} :member_id {:type (non-null Int)} :rating {:type (non-null Int) :description "Game rating as a number between 1 and 5."}} :resolve :mutation/rate-game}}}}
Note kebab case is invalid in GraphQL schema. Note: JDBC defaults to a connection for operation. This is why we use an external library to handle pooling.
(ns lacinia-tutorial.db (:require [com.stuartsierra.component :as component] [clojure.java.jdbc :as jdbc]) (:import (com.mchange.v2.c3p0 ComboPooledDataSource))) (defn ^:private pooled-data-source [host dbname user password port] {:datasource (doto (ComboPooledDataSource.) (.setDriverClass "org.postgresql.Driver" ) (.setJdbcUrl (str "jdbc:postgresql://" host ":" port "/" dbname)) (.setUser user) (.setPassword password))}) (defrecord ClojureGameGeekDb [ds] component/Lifecycle (start [this] (assoc this :ds (pooled-data-source "localhost" "cggdb" "cgg_role" "lacinia" 25432))) (stop [this] (-> ds :datasource .close) (assoc this :ds nil))) (defn new-db [] {:db (map->ClojureGameGeekDb {})}) (defn find-game-by-id [component game-id] (first (jdbc/query (:ds component) ["select game_id, name, summary, min_players, max_players, created_at, updated_at from board_game where game_id = ?" game-id]))) (defn find-member-by-id [component member-id] (->> component :db deref :members (filter #(= member-id (:id %))) first)) (defn list-designers-for-game [component game-id] (let [designers (:designers (find-game-by-id component game-id))] (->> component :db deref :designers (filter #(contains? designers (:id %)))))) (defn list-games-for-designer [component designer-id] (->> component :db deref :games (filter #(-> % :designers (contains? designer-id))))) (defn list-ratings-for-game [component game-id] (->> component :db deref :ratings (filter #(= game-id (:game_id %))))) (defn list-ratings-for-member [component member-id] (->> component :db 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 :db (swap! update :ratings apply-game-rating game-id member-id rating)))
We basically setup pooling in the start of the component, and we modify our code that retrieved data to query these through SQL.
./docker-up.sh; ./setup-db.sh./docker-up.sh
Now let's try again our query:
(start) (q "{ game_by_id(id: 1234) { game_id name summary min_players max_players }}")
Let's improve the user.clj:
(ns user (:require [com.walmartlabs.lacinia :as lacinia] [clojure.java.browse :refer [browse-url]] [lacinia-tutorial.system :as system] [clojure.walk :as walk] [com.stuartsierra.component :as component]) (:import (clojure.lang IPersistentMap))) (defn simplify "Converts all ordered maps nested within the map into standard hash maps, and sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems." [m] (walk/postwalk (fn [node] (cond (instance? IPersistentMap node) (into {} node) (seq? node) (vec node) :else node)) m)) (defonce system nil) (defn q [query-string] (-> system ; here we are deconstructing our system :schema-provider :schema (lacinia/execute query-string nil nil) simplify)) (defn start [] (alter-var-root #'system (fn [_] (-> (system/new-system) component/start-system))) (browse-url "http://localhost:8888/") :started) (defn stop [] (when (some? system) (component/stop-system system) (alter-var-root #'system (constantly nil))) :stopped) (comment (start) (stop) )
Let's harder what we got with some tests.
(ns lacinia-tutorial.server (:require [com.stuartsierra.component :as component] [com.walmartlabs.lacinia.pedestal :as lp] [io.pedestal.http :as http])) (defrecord Server [schema-provider server port] ; this is what we had in the user.clj before component/Lifecycle (start [this] (assoc this :server (-> schema-provider :schema (lp/service-map {:graphiql true :port port}) http/create-server http/start))) (stop [this] (http/stop server) (assoc this :server nil))) (defn new-server [] {:server (component/using (map->Server {:port 8888}) [:schema-provider])})
This is enough configuration to configure a different server port for tests.
Then we extract utility functions:
(ns user (:require [com.walmartlabs.lacinia :as lacinia] [clojure.java.browse :refer [browse-url]] [lacinia-tutorial.system :as system] [lacinia-tutorial.test-utils :as tu] [com.stuartsierra.component :as component]) (:import (clojure.lang IPersistentMap))) (defonce system nil) (defn q [query-string] (-> system ; here we are deconstructing our system :schema-provider :schema (lacinia/execute query-string nil nil) tu/simplify)) (defn start [] (alter-var-root #'system (fn [_] (-> (system/new-system) component/start-system))) (browse-url "http://localhost:8888/") :started) (defn stop [] (when (some? system) (component/stop-system system) (alter-var-root #'system (constantly nil))) :stopped) (comment (start) (stop) )
(ns lacinia-tutorial.test-utils (:require [clojure.walk :as walk]) (:import (clojure.lang IPersistentMap))) (defn simplify "Converts all ordered maps nested within the map into standard hash maps, and sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems." [m] (walk/postwalk (fn [node] (cond (instance? IPersistentMap node) (into {} node) (seq? node) (vec node) :else node)) m))
And let's add an integration test:
(ns lacinia-tutorial.system-tests (:require [clojure.test :refer [deftest is]] [lacinia-tutorial.system :as system] [lacinia-tutorial.test-utils :refer [simplify]] [com.stuartsierra.component :as component] [com.walmartlabs.lacinia :as lacinia])) (defn ^:private test-system "Creates a new system suitable for testing, and ensures that the HTTP port won't conflict with a default running system." [] (-> (system/new-system) (assoc-in [:server :port] 8989))) (defn ^:private q "Extracts the compiled schema and executes a query." [system query variables] (-> system (get-in [:schema-provider :schema]) (lacinia/execute query variables nil) simplify)) (deftest can-read-board-game (let [system (component/start-system (test-system)) results (q system "{ game_by_id(id: 1234) { name summary min_players max_players play_time }}" nil)] (is (= {:data {:game_by_id {:max_players 2 :min_players 2 :name "Zertz" :play_time nil :summary "Two player abstract with forced moves and shrinking board"}}} results)) (component/stop-system system)))
And let's run the test:
rm /tmp/lacinia-tutorial/test/lacinia_tutorial/core_test.clj lein test
And with this we have seen a lot of what we can do with Lacinia!