Overriding cl-json object encoding

CL-JSON provides an encoder for Lisp data structures and objects to JSON format. Unfortunately, in some case, its default encoding mechanism for CLOS objects isn't exactly doing the right thing. I'll show you how Common Lisp makes it easy to change that.
Identifying the problem
CL-JSON & CLOS
CL-JSON mechanism encoding CLOS object is really neat. Let's see how it works for a simple case:
(defclass kitten ()
((tail :initarg :tail)))
(json:encode-json-to-string (make-instance 'kitten :tail 'black))
will produce:
{"tail":"black"}
Still using CL-JSON, we can also decode the JSON object to a CLOS object:
(slot-value
(json:with-decoder-simple-clos-semantics
(json:decode-json-from-string "{\"tail\":\"black\"}"))
:tail)
That code will return "black". Note that it's also possible to specify which class should be used when decoding objects, but that's beyond the purpose of this article.
Postmodern
Now, let's introduce Postmodern, a wonderful Common Lisp system providing access to the wonderful PostgreSQL database. It also provides a simple system to map rows in a database to CLOS classes, called DAO for Database access objects.
With this, we can easily store our kitten into a table.
(defclass kitten ()
((tail :initarg :tail))
(:metaclass postmodern:dao-class))
If we try to encode this to JSON, it will produce the exact same result seen previously.
The problem is what happens when one of our column has a NULL value. Postmodern encodes this using the :null symbol.
So this code:
(defclass kitten ()
((tail :initarg :tail :col-type (or s-sql:db-null text)))
(:metaclass postmodern:dao-class))
(postmodern:deftable kitten
(postmodern:!dao-def))
(postmodern:connect-toplevel …)
(postmodern:create-table 'kitten)
(json:encode-json-to-string
(postmodern:make-dao 'kitten))
will return:
"{"tail":"null"}"
Fail! The fact that the column is NULL is represented by the :null symbol. And CL-JSON encodes all symbols as string.
This is not at all what we want here!
Overriding encode-json
CL-JSON provides and uses the encode-json method to encode all kind of object. It is defined as a generic function, and a lot of different methods are implemented to handle the different standard Common Lisp types. The one used for standard-object is defined liked that:
(defmethod encode-json ((o standard-object)
&optional (stream *json-output*))
"Write the JSON representation (Object) of the CLOS object O to
STREAM (or to *JSON-OUTPUT*)."
(with-object (stream)
(map-slots (stream-object-member-encoder stream) o)))
All we need to do here, is to create a new method for our kitten objects, that handles correctly the :null case.
(defclass kitten ()
((tail :initarg :tail :col-type (or s-sql:db-null text)))
(:metaclass postmodern:dao-class))
(export 'kitten)
;; Switch package just to define the new method
(in-package :json)
(defmethod encode-json ((o cl-user:kitten)
&optional (stream json:*json-output*))
"Write the JSON representation (Object) of the postmodern DAO CLOS object
O to STREAM (or to *JSON-OUTPUT*)."
(with-object (stream)
(map-slots (lambda (key value)
(as-object-member (key stream)
(encode-json (if (eq value :null) nil value) stream)))
o)))
;; Go back into our package
(in-package :cl-user)
(postmodern:deftable kitten
(postmodern:!dao-def))
(postmodern:connect-toplevel …)
(postmodern:create-table 'kitten)
(json:encode-json-to-string
(postmodern:make-dao 'kitten))
With that new method, as soon as we encounter a :null symbol as a value for an object's slot, we replace it by nil.
Now if we try to encode another kitten, we'll get:
{"tail":null}
which is far better for our JavaScript data consumers!
In the end, I think that this kind of trick is feasible that easily because of the way CLOS provides its generic method implementation.
The fact that methods don't belong to any class makes the extension of every program, library and class so much easier. Doing this in another language like Java would likely by impossible, and in Python it would unlikely be as clean as it is done in Common Lisp.
The ability to teach any library about how it should handle your class just by defining a new method is really handy!
