3 April 2021

Handling JSONB with next.jdbc

  1. JSONB Columns in PostGreSQL
  2. jsonista time
  3. Writing a custom object mapper
    1. Let's define a custom mapper that would keywordise our JSON to give us the Clojure Map we need.
    2. Our new pgobject->map function
  4. A step further
  5. Improving the pgobject->map function

JSONB Columns in PostGreSQL

I am assuming you'd know what a JSONB column is in PostGres. For those new to Clojure and next.jdbc, when you query your JSONB column, you'll see something of this sort.
:data #object[org.postgresql.util.PGobject 0x2a8a9ebf "{\"key\": \"value\"}"],

This is not in a state which we can readily use in Clojure. We need this to be a Map for it to be useful.

jsonista time

jsonista is a Clojure library for JSON encoding and decoding. We can extract the value from our PgObject and read the string and turn it into a key-value pair of sorts.

(defn pgobject->map 
        [data] 
        (json/read-value (.getValue data)))

This would convert our JSONB into something of this sort {"key" "value"} which is still not quite the Clojure Map we need.

Writing a custom object mapper

Jsonista's read-value function in its first arity takes in just an object and on its second arity, takes in a custom mapper. If you are not sure what an arity is, do read this article which aptly describes what are multi-arity functions and variadic functions in Clojure.
A summary: Arity refers to the number of arguments you can pass a function. You can pass up to two parameters to json/read-value and if you were to omit the second parameter, it defaults to its default object mapper.

(defn read-value
  "Decodes a value from a JSON from anything that
  satisfies [[ReadValue]] protocol. By default,
  File, URL, String, Reader and InputStream are supported.

  To configure, pass in an ObjectMapper created with [[object-mapper]],
  see [[object-mapper]] docstring for the available options."
  ([object]
   (-read-value object default-object-mapper))
  ([object ^ObjectMapper mapper]
   (-read-value object mapper)))

Let's define a custom mapper that would keywordise our JSON to give us the Clojure Map we need.

  (def keyword-keys-mapper
    (json/object-mapper {:decode-key-fn true}))

Do read the docstring for object-mapper to have a detailed explanation. This essentially coerces the keys into Clojure keywords.

Our new pgobject->map function

(defn pgobject->map 
  [data] 
  (json/read-value (.getValue data) keyword-keys-mapper))

This would give us {:key "value"} Clojure map we need!

A step further

We can have next.jdbc automatically keywordise our JSONB columns by extending the ReadableColumn protocol. Import the namespace (:import (org.postgresql.util PGobject))

(extend-protocol result-set/ReadableColumn
  org.postgresql.util.PGobject
  (read-column-by-label [^org.postgresql.util.PGobject v _]
    (pgobject->map v))
  (read-column-by-index [^org.postgresql.util.PGobject v _2 _3]
    (pgobject->map v)))

I will not get into the weeds of what extend-protocol and likewise for ReadableColumn. The docstring does a great job of that. ^org.postgresql.util.PGobject is what we call a type hint in Clojure and it exists for performance optimisation purposes. We're just explicitly letting JVM know what's the type for parameter v. You may omit it. By extending the protocol, we are effectively running our map function each time we read objects from a java.sql.ResultSet.

Improving the pgobject->map function

We should make this last amendment to add a bit of type safety to our map function.

(defn pgobject->map
  "Transform PGobject containing `json` or `jsonb` value to Clojure
  data."
  [^org.postgresql.util.PGobject v]
  (let [type  (.getType v)
        value (.getValue v)]
    (if (#{"jsonb" "json"} type)
      (json/read-value value keyword-keys-mapper)
      value)))

That should be all it takes to get your JSONB data automatically processed. You can consume the data immediately as a Map type.

Tags: clojure