[Cl-perec-devel] JSON serialization
Mihai Bazon
mihai at bazon.net
Sun Sep 20 09:47:33 UTC 2009
(resending to the list as well)
Maciej Katafiasz <mathrick at gmail.com> wrote:
> On Sat, Sep 19, 2009 at 2:11 PM, Mihai Bazon <mihai at bazon.net> wrote:
> > (declare (ignorable (last-email-from 'mishoo))) ;-)
> >
> > My mistake, it's doable without touching perec... I should take more
> > MOP for breakfast before posting to the list.
>
> Which shouldn't be taken to mean you shouldn't post your results regardless :)
Well, I thought it's a bit lengthy and maybe not interesting for the wizards
on this list and besides, I'm still not very satisfied with the outcome, but
here goes.
Metaclass
=========
I defined a metaclass that will be used for my persistent objects instead of
the default cl-perec:persistent-class. It contains a json-props slot that
collects JSONizable properties as classes using this metaclass are defined.
It's metaclass in turn must be computed-class:computed-class, in order to be
compatible with cl-perec:persistent-class.
(defparameter *json-serialization* 0)
(defclass dlweb.db.metaclass (persistent-class)
((json-props :initform nil :type list :accessor json-props))
(:metaclass computed-class:computed-class))
;; called by CLOS when a class using :metaclass dlweb.db.metaclass is
;; (re)defined
(defmethod shared-initialize :after ((class dlweb.db.metaclass) slot-names
&rest initargs &key direct-slots)
(declare (ignore initargs slot-names))
;; (format t "~A~%~A~%~%" slot-names initargs)
(loop
:for slot :in direct-slots
:for name = (getf slot :name)
:for json = (getf slot :json 0)
:when (and json (not (getf slot :instance)))
:do
(setf (getf (json-props class) name) json)))
Shared-initialize walks through direct-slots (unfortunately I couldn't
figure out how to cover inherited slots as well) and looks for a "json"
property. If not found, the default value is zero (always serializable).
Unless this property is nil, the slot and the value of :json is collected in
json-props.
[ this property specifies the "weight" of the slot for JSON encoding. The
default (zero) means very light and is always serialized, while heavier
props are controlled by *json-serialization*; see encode-json below ]
In order to allow a :json keyword on slot definitions without writing loads
of code to define my own slot classes etc. I had to do the following:
(pushnew :json defclass-star:*allowed-slot-definition-properties*)
Unfortunately, this property can only be accessed in shared-initialize -- it
doesn't really exist in slot objects. But it does the job for now.
Defining serializable objects
=============================
I defined some wrappers around defpclass* and defassociation*:
(defmacro my-defpclass (name &body args)
`(pushnew (defpclass* ,name
, at args
(:metaclass dlweb.db.metaclass))
*dlweb-persistent-classes*))
(defmacro my-def-association (&body args)
`(pushnew (prc::find-association (defassociation* , at args))
*dlweb-persistent-classes*))
[ Note one small inconsistency here, defpclass* returns a class object,
while defassociation* returns an association name instead of the object
itself, so I had to call find-association ]
They automatically add my :metaclass to class definitions and push the newly
created classes in a *dlweb-persistent-classes* variable, which I'll use
next to deploy the schema into the database:
(defparameter *dlweb-persistent-classes* nil)
(defun db-deploy-schema ()
(mapc #'prc::export-to-rdbms (reverse *dlweb-persistent-classes*)))
Base class for serializable objects
===================================
I'm using the following class as the base class for all serializable
objects. It does two things:
1. maintain a version number for all objects (edition-of object). It is
incremented automagically when an object is saved to DB using a
"before-committing-instance" trigger
2. provide an encode-json method that walks through the json-props list and
builds a JSON representation of the object.
(my-defpclass db-versioned-object ()
((edition 0 :type integer-32)))
(defmethod before-committing-instance ((transaction transaction-mixin)
(-self- db-versioned-object)
event)
(declare (ignore transaction))
(when (eq event :modified)
(incf (edition-of -self-)))
(call-next-method))
(defun find-slot (class name)
(when (symbolp class)
(setq class (find-class class)))
(find-if #'(lambda(sl)
(eq (slot-definition-name sl) name))
(class-slots class)))
;; maximum weight allowed for serialization
(defparameter *json-serialization* 0)
;; this one is ugly, should clean it up
(defmethod encode-json ((object db-versioned-object) &optional stream)
(let ((props
(loop
:for (name weight) :on (json-props (class-of object)) :by #'cddr
:when (<= weight *json-serialization*)
:collect (cons name (let* ((slot (find-slot (class-of object) name))
(val (slot-value object name)))
(cond
((typep slot 'persistent-association-end-effective-slot-definition)
(if (listp val)
(mapcar #'oid-of val)
(oid-of val)))
(t
val)))))))
;; note that because inherited slots are not covered by the
;; metaclass, edition must be added manually. This sucks.
(push (cons 'edition (edition-of object)) props)
(push (cons 'id (oid-of object)) props)
(encode-json props stream)))
;; you might also find the following useful: it encodes timestamp
;; objects as number of milliseconds since Jan 1 1970 00:00:00 UTC,
;; which I find preferable in JS as I can say new Date(milliseconds)
(defmethod encode-json ((value timestamp) &optional stream)
(encode-json (+ (* 1000 (timestamp-to-unix value))
(timestamp-millisecond value)) stream))
Example usage
=============
(my-defpclass db-user (db-versioned-object)
((create-time (transaction-timestamp) :type timestamp :index t)
(last-seen nil :type (or null timestamp) :index t)
(userid :type (text 32) :unique t)
(name nil :type (or null (text 64)) :index t)
(email :type (text 128) :unique t)
(password nil :type (or nil (text 128)) :json nil))) ;; password not serialized
(my-defpclass db-group (db-versioned-object)
((name :type (text 32) :unique t)
(description :type (text 255))))
(my-def-association
((:class db-user :slot groups :type (prc:set db-group) :json 5) ;; 5 is "heavy"
(:class db-group :slot users :type (prc:set db-user) :json nil)) ;; don't serialize users
(:cache t))
(db-deploy-schema)
(defvar groups
(with-transaction
(list (make-instance 'db-group
:name "admin"
:description "Power is not for everyone, so take care!")
(make-instance 'db-group
:name "editor"
:description "Users allowed to edit pages")
(make-instance 'db-group
:name "programmer"
:description "Users allowed to mess with internals"))))
(defvar u
(with-transaction
(make-instance 'db-user
:name "Mihai Bazon"
:userid "mishoo"
:email "mihai at bazon.net"
:password "foo"
:groups groups)))
(with-transaction
(revive-instance u)
(encode-json u))
;; {"id":325330,"edition":0,"email":"mihai at bazon.net","name":"Mihai
;; Bazon","userid":"mishoo","lastSeen":null,"createTime":1253439288138}
(with-transaction
(revive-instance u)
(encode-json (groups-of u)))
;; [{"id":69012,"edition":0,"description":"Power is not for everyone,
;; so take
;; care!","name":"admin"},{"id":134548,"edition":0,"description":"Users
;; allowed to edit
;; pages","name":"editor"},{"id":200084,"edition":0,"description":"Users
;; allowed to mess with internals","name":"programmer"}]
;; the following includes the "groups" array because we increase the
;; maximum weight:
(let ((*json-serialization* 5))
(with-transaction
(revive-instance u)
(encode-json u)))
;; {"id":325330,"edition":0,"comments":null,"pages":null,
;; "groups":[69012,134548,200084],"email":"mihai at bazon.net","name":"Mihai Bazon",
;; "userid":"mishoo","lastSeen":null,"createTime":1253439288138}
That's it
=========
Hope someone will find it useful. I'm still learning, if I made any blatant
mistake or if anything I wrote can be written nicer/cleaner, I'd appreciate
any hints. ;-)
Cheers,
-Mihai
More information about the cl-perec-devel
mailing list