Leveraging Clojure-power for SQL generation with HoneySQL

Table of Contents

One of the most meaningful testaments to Clojure’s data-driven and lisp-powered design is the ability to use it to refine any of its transpiling processes, here SQL generation (elsewhere route generation, CSS generation, and of course its actual hosted languages, including Javascript or Java generation*). In this post I was tasked with refactoring a 3-arg function that reads from the database into a map-taking function that works with one, two, or three items in the map. I have come to love cond-> for uses like this; watch how it came to work. The goal is to cause the database to do what it does best, knowing that often a database can do its sorting, searching, joining, and filtering more efficiently than raw Clojure (or any other programming language), while also producing elegant, simple code.

Original fn

This fn takes three args (since this was written, 3 became my cut-off point for when I start using maps instead of sequential args). The db/pg* fns act on postgres data structures: one is for seeing if a postgres array (of each email recipient of an email here) contains the target email address, and pg->> causes Postgres to do deep retrieval on nested json structures.

(defn mail-for-user 
  [user-id application-id type-s]
  (let [email-addresses (map :email (email-addresses-for-user user-id))]
    (-> {:select [:*]
         :from [:email-log]
         :where [:and
                 (db/pg-intersect-arrays :recipients email-addresses)
                 [:= (db/pg->> :info :application_id) (str application-id)]
                 [:= (db/pg->> :info :type) type-s]]}
        sql/format
        db/dbr)))

Reworked fn

Existing functionality is fully maintained because the three-varity version just passes on to the map-version (a relatively rare case of higher varity reducing to lower; more standard is low arities building to larger arities by applying defaults). Meanwhile, we add functionality: with cond-> we add lines to the “where” clause depending on the presence of keys. As a result, we get three distinct, intuitive functionalities for our function: select with type (those include "receipt" emails or "request" emails), select emails applying to a particular client application, or a DB-powered combination of both.

(defn mail-for-user
  ([{:keys [user-id application-id type-s]}]
   (let [email-addresses (map :email (email-addresses-for-user user-id))]
     (-> {:select [:*]
          :from [:email-log]
          :where (cond->
                     [:and
                      (db/pg-intersect-arrays :recipients email-addresses)]

                   application-id ;; if we specified that we are interested in emails regarding a specific application
                   (conj 
                    [:= (db/pg->> :info :application_id) (str application-id)])

                   type-s ;; if we specified that we are interested in emails of a certain type
                   (conj 
                    [:= (db/pg->> :info :type) type-s]))}
         sql/format
         db/dbr)))

         ;; Former functionality below
  ([user-id application-id type-s]
   (mail-for-user {:user-id user-id :application-id application-id :type-s type-s})))

Conclusion

At the end of the day we’ve entirely maintained previous functionality (the three sequential arg version) while meaningfully adding two new arities while leaving Postgres to do what it does best. Everyone wins.

Tory Anderson avatar
Tory Anderson
Web App Engineer, Digital Humanist, Researcher, Computer Psychologist