Resolvers

In Connect, you implement the graph by creating resolvers. Those resolvers are functions that expose some data on the graph.

A resolver has a few basic elements:

  1. Inputs – A set of attributes that are required to be in the current parsing context for the resolver to be able to work. Inputs is optional, no inputs means that the resolver is always capable of working, independently of the current parsing context.

  2. Outputs - A query-like notation representing the shape of data the resolver is able to resolve. This is typically a list of attributes/joins, where joins typically include a simple subquery.

  3. A function - A (fn [env input-data] tree-of-promised-output) that takes the inputs and turns them into a tree that satisfies the "output query".

So you might define a resolver like this:

(pc/defresolver person-resolver
  [{:keys [database] :as env} {:keys [person/id]}]
  {::pc/input #{:person/id}
   ::pc/output [:person/first-name :person/age]}
  (let [person (my-database/get-person database id)]
    {:person/age        (:age person)
     :person/first-name (:first-name person)}))
If you use Cursive, you can ask it to resolve the pc/defresolver as a defn and you will get proper symbol resolution

Where the database in the environment would be supplied when running the parser, and the input would have to be found in the current context. Remember that graph queries are contextual…​ you have to have a starting node to work from, so in the above example we’re assuming that during our parse we’ll reach a point where the context contains a :person/id. The my-database stuff is just made up for this example, and is intended to show you that your data source does not need to remotely match the schema of your graph query.

Pathom will scan through the defined resolvers in order to try to satisfy all of the properties in a query. So, technically you can split up your queries as much as makes sense into separate resolvers, and as long as the inputs are in the context Pathom will assemble things back together.

Of course, it doesn’t make sense in this case to do so, because each resolver would end up running a new query:

(pc/defresolver person-age-resolver [{:keys [database] :as env} {:keys [person/id]}]
  {::pc/input #{:person/id}
   ::pc/output [:person/age]}
  (let [person (my-database/get-person database id)]
    {:person/age (:age person)}))

(pc/defresolver person-first-name-resolver [{:keys [database] :as env} {:keys [person/id]}]
  {::pc/input #{:person/id}
   ::pc/output [:person/first-name]}
  (let [person (my-database/get-person database id)]
    {:person/first-name (:first-name person)}))

...

The point is that a single-level query like [:person/id :person/first-name :person/age] can be satisfied and "folded together" by Pathom over any number of resolvers.

This fact is the basis of parser (de)composition and extensibility. It can also come in handy for performance refinements when there are computed attributes.

Derived/Computed Attributes

There are times when you’d like to provide an attribute that is computed in some fashion. You can, of course, simply compute it within the resolver along with other properties like so:

(pc/defresolver person-resolver [{:keys [database] :as env} {:keys [person/id]}]
  {::pc/input #{:person/id}
   ::pc/output [:person/first-name :person/last-name :person/full-name :person/age]}
  (let [{:keys [age first-name last-name]} (my-database/get-person database id)]
    {:person/age        age
     :person/first-name first-name
     :person/last-name  last-name
     :person/full-name  (str first-name " " last-name) ; COMPUTED
     ...}))

but this means that you’ll take the overhead of the computation when any query relate to person comes up. You can instead spread such attributes out into other resolvers as we discussed previously, which will only be invoked if the query actually asks for those properties:

(pc/defresolver person-resolver [{:keys [database] :as env} {:keys [person/id]}]
  {::pc/input #{:person/id}
   ::pc/output [:person/first-name :person/last-name :person/age]}
  (let [{:keys [age first-name last-name]} (my-database/get-person database id)]
    {:person/age        age
     :person/first-name first-name
     :person/last-name  last-name}))

(pc/defresolver person-name-resolver [_ {:person/keys [first-name last-name]}]
  {::pc/input #{:person/first-name :person/last-name}
   ::pc/output [:person/full-name]}
  {:person/full-name (str first-name " " last-name)})

This combination of resolvers can still resolve all of the properties in [:person/full-name :person/age] (if :person/id is in the context), but a query for just [:person/age] won’t invoke any of the logic for the person-name-resolver.

Single Inputs — Establishing Context

So far we have seen how to define a resolver that can work as long as the inputs are already in the environment. You’re almost certainly wondering how to do that.

One way is to define global resolvers and start the query from them, but very often you’d just like to be able to say "I’d like the first name of person with id 42."

EQL uses "idents" to specify exactly that sort of query:

[{[:person/id 42] [:person/first-name]}]

The above is a join on an ident, and the expected result is a map with the ident as a key:

{[:person/id 42] {:person/first-name "Joe"}}

The query itself has everything you need to establish the context for running the person-resolver, and in fact that is how Pathom single-input resolvers work.

If you use an ident in a query then Pathom is smart enough to know that it can use that ident to establish the context for finding resolvers. In other words, in the query above the ident [:person/id 42] is turned into the parsing context {:person/id 42}, which satisfies the input of any resolver that needs :person/id to run.

Resolver Without Input — Global Resolver

A resolver that requires no input can output its results at any point in the graph, thus it is really a global resolver. Pay a particular attention to the "at any point in the graph" - it’s not just at the root. Thus, a resolver without inputs can "inject" its outputs into any level of the query graph result.

We’re going to start building a parser that can satisfy queries about a music store. So, we’ll start with a global resolver that can resolve the "latest product". The code below shows the entire code needed, boilerplate and all:

(ns com.wsscode.pathom.book.connect.getting-started
  (:require [com.wsscode.pathom.core :as p]
            [com.wsscode.pathom.connect :as pc]
            [com.wsscode.pathom.profile :as pp]))

; creating our first resolver
(pc/defresolver latest-product [_ _]
  {::pc/output [{::latest-product [:product/id :product/title :product/price]}]}
  {::latest-product {:product/id    1
                     :product/title "Acoustic Guitar"
                     :product/price 199.99M}})

(def parser
  (p/parser
    {::p/env     {::p/reader               [p/map-reader
                                            pc/reader2
                                            pc/open-ident-reader
                                            p/env-placeholder-reader]
                  ::p/placeholder-prefixes #{">"}}
     ::p/mutate  pc/mutate
     ::p/plugins [(pc/connect-plugin {::pc/register latest-product})
                  p/error-handler-plugin
                  p/trace-plugin]}))

(comment
  (parser {} [::latest-product]))

Our first resolver exposes the attribute ::latest-product, and since it doesn’t require any input it is a global resolver. Also, note that our output description includes the full output details (including nested attributes), this is mostly useful for auto-completion on UI’s and automatic testing. If you return extra data it will still end up in the output context.

Try some of these queries on the demo below:

[::latest-product]
[{::latest-product [:product/title]}]

; ::latest-product can be requested anywhere
[{::latest-product
  [* ::latest-product]}]
[::latest-product]

Resolvers with single input

Next, let’s say we want to have a new attribute which is the brand of the product. Of course, we could just throw the data there in our other resolver, but the real power of Connect comes out when we start splitting the responsibilities among resolvers, so let’s define a resolver for brand that requires an input of :product/id:

(ns com.wsscode.pathom.book.connect.getting-started2
  (:require [com.wsscode.pathom.core :as p]
            [com.wsscode.pathom.connect :as pc]))

(def product->brand
  {1 "Taylor"})

(pc/defresolver latest-product [_ _]
  {::pc/output [{::latest-product [:product/id :product/title :product/price]}]}
  {::latest-product {:product/id    1
                     :product/title "Acoustic Guitar"
                     :product/price 199.99M}})

(pc/defresolver product-brand [_ {:keys [product/id]}]
  {::pc/input  #{:product/id}
   ::pc/output [:product/brand]}
  {:product/brand (get product->brand id)})

(def app-registry [latest-product product-brand pc/index-explorer-resolver])

(def parser
  (p/parser
    {::p/env     {::p/reader [p/map-reader
                              pc/reader2
                              pc/open-ident-reader]}
     ::p/mutate  pc/mutate
     ::p/plugins [(pc/connect-plugin {::pc/register app-registry})
                  p/error-handler-plugin
                  p/trace-plugin]}))
[{::latest-product [:product/title :product/brand]}]

The input is a set containing the keys required on the current entity in the parsing context for the resolver to be able to work. This is where Connect starts to shine because any time your query asks for a bit of data it will try to figure it out how to satisfy that request based on the attributes that the current contextual entity already has.

More importantly: Connect will explore the dependency graph in order to resolve things if it needs to! To illustrate this, let’s pretend we have some external ID for the brand and that we can derive this ID from the brand string - pretty much just another mapping:

;; a silly pretend lookup
(def brand->id {"Taylor" 44151})

(pc/defresolver brand-id-from-name [_ {:keys [product/brand]}]
  {::pc/input #{:product/brand}
   ::pc/output [:product/brand-id]}
  {:product/brand-id (get brand->id brand)})

(comment
  (parser {} [{::latest-product [:product/title :product/brand-id]}])
  ; => #::{:latest-product #:product{:title "Acoustic Guitar", :brand-id 44151}}
)

Note that our query never said anything about the :product/brand. Connect automatically walked the path :product/id → :product/brand → :product/brand-id to obtain the information desired by the query!

When a required attribute is not present in the current entity, Connect will look for resolvers that can fetch it, analyze their inputs, and recursively walk backwards towards the "known data" in the context. When a required attribute is not present in the current entity, Connect will calculate the possible paths from the data you have to the data you request, then it can use some heuristic to decide which path to take and walk this path to reach the data, if there is no possible path connect reader will return ::p/continue to let another reader try to handle that key. You can read more about how this works in the Index page.

Also remember that single-input resolvers can handle ident-based queries. Thus, the following ident-join queries already work without having to define anything else:

(parser {} [{[:product/id 1] [:product/brand]}])
; => {[:product/id 1] #:product{:brand "Taylor"}}

(parser {} [{[:product/brand "Taylor"] [:product/brand-id]}])
; => {[:product/brand "Taylor"] #:product{:brand-id 44151}}

Multiple inputs

The input to a resolver is a set, and as such you can require more than one thing as input to your resolvers. When doing so, of course, your resolver function will receive all of the inputs requested; however, this also means that the parsing context needs to contain them, or there must exist other resolvers that can use what’s in the context to fill them in.

As you have seen before, the only way to provide ad-hoc information to connect is using the ident query, but in the ident itself you can only provide one attribute at a time.

Since version 2.2.0-beta11 the ident readers from connect (ident-reader and open-ident-reader) support adding extra context to the query using parameters. Let’s say you want to load some customer data but you want to reduce the number of resolvers called by providing some base information that you already have, you can issue a query like this:

[{([:customer/id 123] {:pathom/context {:customer/first-name "Foo" :customer/last-name "Bar"}})
  [:customer/full-name]}]

Union Queries

In connect, unions have a default branching implementation that tries to find the union key in the current entity, when its found, that branch is taken.

For example, consider the following query:

[{:app/feed
  {:app.post/id
   [:app.post/id :app.post/text :app.post/author]

   :app.video/id
   [:app.video/id :app.video/stream-url :app.video/duration-ms]

   :app.image/id
   [:app.image/id :app.image/source-url :app.image/type :app.image/dimensions]}}]

Note that the key for each union is an attribute on itself, so when data like this comes:

[{:app/feed [{:app.post/id 1 :app.post/text "foo"}
             {:app.video/id 2 :app.video/duration-ms 42143880}
             {:app.image/id 3 :app.image/type :app.image.type/png}]}]

When deciding the branch, the presence of the key attribute on each entry is what decides the branch that will be taken.

Note that the attribute check will be tried in the order that the map gets scanned, recent versions of Clojure make it consistent with the order you wrote than in the map declaration.

So in case you have the chance of records hitting multiple attributes, put the ones with the highest priority at the top of the map.

Resolver with unions demo:

[{:app/feed {:app.post/id [:app.post/id :app.post/text :app.post/author] :app.video/id [:app.video/id :app.video/stream-url :app.video/duration-ms] :app.image/id [:app.image/id :app.image/source-url :app.image/type :app.image/dimensions]}}]

Source code for this demo:

(ns com.wsscode.pathom.book.connect.unions
  (:require
    [com.wsscode.pathom.connect :as pc]
    [com.wsscode.pathom.core :as p]))

(pc/defresolver feed [_ _]
  {::pc/output
   [{:app/feed
     {:app.post/id
      [:app.post/id :app.post/text]

      :app.video/id
      [:app.video/id :app.video/stream-url]

      :app.image/id
      [:app.image/id :app.image/source-url]}}]}
  {:app/feed [{:app.post/id 1 :app.post/text "foo"}
              {:app.video/id 2 :app.video/stream-url "http://my-site/video.mp4"}
              {:app.image/id 3 :app.image/source-url "http://my-site/image.png"}]})

(def duration-db
  {2 42143880})

(defn type-from-extension [^string source-url]
  (let [ext (subs source-url (inc (.lastIndexOf source-url ".")))]
    (keyword "app.image.type" ext)))

(def app-registry
  [feed
   (pc/single-attr-resolver :app.video/id :app.video/duration-ms duration-db)
   (pc/single-attr-resolver :app.image/source-url :app.image/type type-from-extension)])

(def parser
  (p/parallel-parser
    {::p/env     {::p/reader [p/map-reader
                              pc/parallel-reader
                              pc/open-ident-reader]}
     ::p/mutate  pc/mutate-async
     ::p/plugins [(pc/connect-plugin {::pc/register app-registry})
                  p/error-handler-plugin
                  p/trace-plugin]}))

Resolver builders

There are some common resolver patterns that emerge from usage, Pathom connect provides helpers for such cases, the next sections describes the Connect core available helpers.

Alias resolvers

Sometimes you might want to make an alias, that is, create a resolver which just converts one name to another. For example:

(pc/defresolver alias-youtube-video [env {:user/keys [youtube-video-url]}]
  {::pc/input  #{:user/youtube-video-url}
   ::pc/output [:youtube.video/id]}
  {:youtube.video/id youtube-video-url})

The previous resolver will convert :user/youtube-video-url to :youtube.video/id. To make that easy, Pathom provides some helpers:

; this returns a resolver that works just like the previous resolver
(def alias-youtube-video (pc/alias-resolver :user/youtube-video-url :youtube.video/id))

If you want to create an alias that goes in both directions, use pc/alias-resolver2.

Constant resolvers

This is a resolver that always returns a constant value for a given key. A common use case is use this to set some default.

; using helper
(def answer-to-everything
  (pc/constantly-resolver :douglas.adams/answer-to-everything 42))

; is equivalente to:
(pc/defresolver answer-to-everything [_ _]
  {::pc/output [:douglas.adams/answer-to-everything]}
  {:douglas.adams/answer-to-everything 42})

Single attribute resolvers

For cases of a single transition from one attribute to another, you can use the single-attr-resolver helper:

; convertion fn
(defn fahrenheit->celcius [x]
  (-> x (- 32) (* 5/9)))

; resolver with helper
(def f->c-resolver
  (pc/single-attr-resolver :unit/fahrenheit :unit/celcius fahrenheit->celcius))

; is equivalent to:
(pc/defresolver answer-to-everything [_ {:keys [unit/fahrenheit]}]
  {::pc/input  #{:unit/fahrenheit}
   ::pc/output [:unit/celcius]}
  {:unit/celcius (fahrenheit->celcius fahrenheit)})

Sometimes you also need to get some data from the environment, for those cases use single-attr-resolver2, the difference is that this one will send env and the value input as arguments to the provided function:

(defn single-with-env [env value]
  (* value (::multiplier env))

(def env-demo-resolver
  (pc/single-attr-resolver2 ::some-value ::other-value single-with-env))

; is equivalent to
(pc/defresolver answer-to-everything [env {::keys [some-value]}]
  {::pc/input  #{::some-value}
   ::pc/output [::other-value]}
  {::other-value (single-with-env env some-value)})

Parameters

Parameters enable another dimension of information to be added to the request. Params have different semantics from inputs: inputs are more a dependency thing while params are more like options. In practice, the main difference is that inputs are something Pathom will try to look up and make available, while parameters must always be provided at query time, there have no auto resolution. Common cases to use parameters are: pagination, sorting, filtering…​

Let’s write a resolver that outputs a sequence of instruments which can optionally be sorted via a sorting criteria specified via a parameter.

(pc/defresolver instruments-list [env _]
  {::pc/output [{::instruments [:instrument/id :instrument/brand
                                :instrument/type :instrument/price]}]}
  (let [{:keys [sort]} (-> env :ast :params)] ; (1)
    {::instruments (cond->> instruments
                     (keyword? sort) (sort-by sort))}))
1 Pulls the parameters from the environment

Then we can run queries like:

[(::instruments {:sort :instrument/brand})]
[(::instruments {:sort :instrument/price})]
[(::instruments {:sort :instrument/type})]

; params with join

[{(::instruments {:sort :instrument/price})
  [:instrument/id
   :instrument/brand]}]

Try it out:

[(::instruments {:sort :instrument/price})]

Note: If you are calling the parser directly, be sure to quote your query when using parameters like so:

(parser {} '[(::instruments {:sort :instrument/brand})])
; => {::instruments
      ({:instrument/id 4,
        :instrument/brand "Cassio",
        :instrument/type :instrument.type/piano,
        :instrument/price 160}
       {:instrument/id 1,
        :instrument/brand "Fender",
        :instrument/type :instrument.type/guitar,
        :instrument/price 300}
        ...

N+1 Queries and Batch resolvers

When you have a to-many relation that is being resolved by a parser, you will typically end up with a single query that finds the "IDs", and then N more queries to fill in the details of each item in the sequence. This is known as the N+1 problem, and can be a source of significant performance problems.

Instead of running a resolver once for each item on the list, the idea to solve this problem is to send all the inputs as a sequence, so the resolver can do some optimal implementation to handle multiple items. When this happens, we call it a batch resolver. For example, let’s take a look at the following demo:

(ns com.wsscode.pathom.book.connect.batch
  (:require [com.wsscode.pathom.core :as p]
            [com.wsscode.pathom.connect :as pc]
            [cljs.core.async :as async :refer [go]]
            [com.wsscode.pathom.profile :as pp]))

(pc/defresolver list-things [_ _]
  {::pc/output [{:items [:number]}]}
  {:items [{:number 3}
           {:number 10}
           {:number 18}]})

(pc/defresolver slow-resolver [_ {:keys [number]}]
  {::pc/input  #{:number}
   ::pc/output [:number-added]}
  (go
    (async/<! (async/timeout 1000))
    {:number-added (inc number)}))

(def app-registry [list-things slow-resolver])

(def parser
  (p/async-parser
    {::p/env     {::p/reader [p/map-reader
                              pc/async-reader2
                              pc/open-ident-reader]}
     ::p/mutate  pc/mutate-async
     ::p/plugins [(pc/connect-plugin {::pc/register app-registry})
                  p/error-handler-plugin
                  p/trace-plugin]}))

Try the demo:

[{:items [:number-added]}]
This demo is using Pathom async parsers. The resolvers in async parsers can return channels that (eventually) resolve to the result, which is why you see go blocks in the code. See Async Parsing for more details. We use them in this demo so we can "sleep" in a Javascript environment to mimic overhead in processing.

You can note by the tracer that it took one second for each entry, a clear cascade, because it had to call the :number-added resolver once for each item.

We can improve that by turning this into a batch resolver, like this:

(ns com.wsscode.pathom.book.connect.batch2
  (:require [com.wsscode.pathom.core :as p]
            [com.wsscode.pathom.connect :as pc]
            [cljs.core.async :as async :refer [go]]))

(pc/defresolver list-things [_ _]
  {::pc/output [{:items [:number]}]}
  {:items [{:number 3}
           {:number 10}
           {:number 18}]})

(pc/defresolver slow-resolver [_ input]
  {::pc/input  #{:number}
   ::pc/output [:number-added]
   ::pc/batch? true}
  (go
    (async/<! (async/timeout 1000))
    ; the input will be sequential if a batch opportunity happens
    (if (sequential? input)
      ; this will return a list of results, this order should match the input order, like this:
      ; [{:number-added 4}
      ;  {:number-added 11}
      ;  {:number-added 19}]
      (mapv (fn [v] {:number-added (inc (:number v))}) input)
      ; the else case still handles the single input case
      {:number-added (inc (:number input))})))

(def app-registry [list-things slow-resolver])

(def parser
  (p/async-parser
    {::p/env     {::p/reader        [p/map-reader
                                     pc/async-reader2
                                     pc/open-ident-reader]
                  ::p/process-error (fn [env error]
                                      (js/console.error "ERROR" error)
                                      (p/error-str error))}
     ::p/mutate  pc/mutate-async
     ::p/plugins [(pc/connect-plugin {::pc/register app-registry})
                  p/error-handler-plugin
                  p/trace-plugin]}))

Try the demo:

[{:items [:number-added]}]

Note that this time the sleep of one second only happened once, this is because when Pathom is processing a list and the resolver supports batching, the resolver will get all the inputs in a single call, so your batch resolver can get all the items in a single iteration. The results will be cached back for each entry, this will make the other items hit the cache instead of calling the resolver again.

Batch transforms

Starting on version 2.2.0 Pathom add some helpers to facilitate the creation of batch resolvers using Pathom transform facilities.

In the previous example we manually detected if input was a sequence, this API is made this way so the resolver keeps compatibility with the regular resolver API, but often it is easier if you get a consistent input (always a sequence for example). We can enforce this using a transform:

(pc/defresolver slow-resolver [_ input]
  {::pc/input     #{:number}
   ::pc/output    [:number-added]
   ; use the transform, note we removed ::pc/batch? true, that's because the transform
   ; will add this for us
   ::pc/transform pc/transform-batch-resolver}
  (go
    (async/<! (async/timeout 1000))
    ; no need to detect sequence, it is always a sequence now
    (mapv (fn [v] {:number-added (inc (:number v))}) input)))

Try the demo:

[{:items [:number-added]}]

Another helper that Pathom provides is to transform a serial resolver that would run one by one, into a batch that runs at concurrency n.

(pc/defresolver slow-resolver [_ {:keys [number]}]
  {::pc/input     #{:number}
   ::pc/output    [:number-added]
   ; set auto-batch with concurrency of 10
   ::pc/transform (pc/transform-auto-batch 10)}
  (go
    (async/<! (async/timeout 1000))
    ; dealing with the single case, as in the first example we did on batch
    {:number-added (inc number)}))

Try the demo:

[{:items [:number-added]}]

Note this time we did called resolver fn multiple times but in parallel, the way this may impact the performance will vary case by case, I suggest giving some thought on the best strategy for each case individually.

Aligning results

Often times when you do a batch request to some service/api the results won’t come in the same order of the request, also the count might not match in case some of the items on request were invalid. To facilitate the coding of these cases Pathom provides a helper to correctly sort the results back, for more info check the docs about batch-restore-sort on cljdoc.