Pattern matching as a tool for refactoring
Pattern matching is useful even if you don't end up using `match` because it helps you reason about your code
Table of Contents
1. A small example
Take the following snippet, slightly simplified from the original:
(defn reformat-req
[{:keys [procid params] :as req}]
(let [aq (if (:id params) {:id (:id params)} {})
query {:admin-data aq
:tenant (:tenant params)}
q {:type/procid procid
:type/time (now)
:type/tenant (:tenant params)
:type/user (get-user req)
:type/status :processing
:query/request query}]
q))
Currently query looks like this:
;; when the :id key is in the params map
{:admin-data {:id id}
:tenant tenant}
;; when the :id key is NOT present
{:admin-data {}
:tenant tenant}
I need to refactor query to be like this:
{:type :admin ; required
:id id ; optional
:tenant tenant ; optional
}
We know that the params map has at least the :tenant key, and it might
have a :id key as well. Knowing this, we can identify 2 possible patterns that
the data could be in:
- Contains both
:tenantand:id - Contains just
:tenant
2. Pattern Matching to the Rescue
A perfect solution for this is pattern matching
(match params
;; 1. Contains both `:tenant` and `:id`
{:tenant t :id d} {:type :admin :tenant t :id d}
;; 2. Contains just `:tenant`
{:tenant t} {:type :admin :tenant t}
;; 3. Fall thru in case neither is present
_ {:type :admin})
The match function from
core.match first
takes a map to match on, in this case params. Then, it takes a series of
patterns and returns. In the first case, we want to match a map that has both
:tenant and :id, and we want to extract the values for each key as t and
d respectively. Then, we put that in a new map with :type :admin and return
the entire thing to the caller. The second pattern, we only extract :tenant,
and the third is a fail-safe in case the caller forgot to add :tenant for
whatever reason (according to the spec, :tenant is technically optional). _
in this case is a wildcard.
Nota bene: we have to order our matches from most-specific to
least-specific. For example if we put the {:tenant t} pattern first, then it
could trigger a match even though there is a :id present.
3. A Simpler Way
Now you might look at this and say, "it sure looks like you are repeating yourself a lot." In which case you would be right, and there is a simpler way to write this code:
(merge {:type :admin} (select-keys params [:id :tenant]))
This does the same thing as the pattern matching. select-keys will take out
:id and :tenant if they are present in params, and then we merge those
keys with {:type :admin}. We can't use get in this case because, according
to the spec, if the keys are in the final map, then they must not be nil, and
get will return nil if it doesn't find the key in the params map.
However, it's very hard to see the succinct answer we came up with from the
first code snippet. In this case, going through the exercise of pattern matching
is valuable even though we didn't end up using match because it made the
structure of the data explicit. In the original snippet, the data structure was
there, but it was difficult to see clearly in all the moving parts.
4. The full refactor
At the end of the day, here is the full progression of refactoring:
;; original
(defn reformat-req
[{:keys [procid params] :as req}]
(let [aq (if (:id params) {:id (:id params)} {})
query {:admin-data aq
:tenant (:tenant params)}
q {:type/procid procid
:type/time (now)
:type/tenant (:tenant params)
:type/user (get-user req)
:type/status :processing
:query/request query}]
q))
;; core.match version
(defn reformat-req
[{:keys [procid params] :as req}]
(let [query (match params
{:tenant t :id d} {:type :admin :tenant t :id d}
{:tenant t} {:type :admin :tenant t}
_ {:type :admin})
q {:type/procid procid
:type/time (now)
:type/tenant (:tenant params)
:type/user (get-user req)
:type/status :processing
:query/request query}]
q))
;; final, simplified
(defn reformat-req
[{:keys [procid params] :as req}]
(let [query (merge {:type :admin} (select-keys params [:id :tenant]))]
{:type/procid procid
:type/time (now)
:type/tenant (:tenant params)
:type/user (http-request->user req)
:type/status :processing
:query/request query}))