How do you tie your specs to your Document DB usage?

When using a document database I am schemaless, meaning I no longer have an init.sql that I frequently consult to ensure I’m adding the right fields. One good answer, as mentioned by didibus, is to leverage Spec for this. So instead of definitions for each table in init.sql, I’ve built myself a cljc file which contains entries like this:

(ns centrifuge.crux-specs
  "Specs for Crux documents"
   [clojure.spec.alpha :as s]))

(s/def ::doctype #{"material"

(s/def :user/role #{"admin"

(s/def :centrifuge.record/v1
  (s/keys :req [:material/id
	  :req-un [::doctype]
	  :opt [:record/tags]))

I’m not a pro with spec. The height of my appreciation is the way re-frame allows specs to be automatically connected with front-end db transactions, failing if the spec check fails. Other than that, though, I’m not sure what best practices are from here: are my specs missing anything? What’s the most efficient way to make use of them?

Here is my current attempt at this: I use (assert) to check if the map matches a spec before I add it to the database. Which spec it should match is composed by two keys expected to be in the map. What do you think? Is there a simpler way to accomplish this?

(s/def :centrifuge.user/v1
  (s/keys :req [:doctype/version
          :req-un [::doctype]))

(defn construct-specname
  "Construct the name of the spec from the `:doctype` and `:doctype/version` of `m`,
  producing something like `:centrifuge.user/v1` which should match an existing spec"
  (let [{t :doctype v :doctype/version} m]
    (keyword (str "centrifuge." t) (str "v" v))))

(defn crux-conforms?
  "Determine whether a map conforms as one of our maps"
  (let [specname (construct-specname m)]
    (s/conform specname m)))

(defn explain-str
  "Explain conformity of the given map `m`"
  (let [specname (construct-specname m)]
    (s/explain-str specname m)))

(defn PUT
  "Create something in crux"
  ([crux m]
   (assert (crux-conforms? m)
           (str "Invalid document: "
                (cspecs/explain-str m)))
   (crux/submit-tx crux [:crux.tx/put m])))

Yes, I current leverage specs in some places to make sure that I am receiving the expected inputs for a particular function. For example:

(:require [my.user-domain.specs :as spec-user])

(defn compute-salary [user]
  {:pre [(s/valid? ::spec-user/user user)]}
  (let [base-salary 10
        role-multiplier {:manager 10 :assistant 5}]
    (* (get role-multiplier (:user/role user)) base-salary)))

Sometimes I also use :post constraint too if I need to make sure that the output has some specific shape/properties. This post from Fogus has more examples of this type of contraints clojures-pre-and-post

However you have to take some precaution when doing this to save directly to database because if you have extra additional fields in the user map the validity check will let it pass and you might be saving more data than you were willing to or even breaking something.

(s/def ::name string?)
(s/def ::role keyword?)

(s/def ::user (s/keys :req-un [::name ::role]))

(s/valid? ::user {:name "Wand" :role :assistant :picture "base64"});; => true

Both in terms of performance and the ability to make the spec closed, you might want to consider malli.

I had wondered at whether using :pre and :post vs assert. Answers found here:

tl;dr: use pre and post is more flexible and can be used with asserts.

I’d like to hear more about both those points: closed spec, and performance.

For those who haven’t scouted metosin stuff recently, malli is here:

1 Like

Don’t know how much I can add on the topic besides:

  • performance: malli is designed with performance in mind, allowing you to get the fastest possible validation. Its specs are data structures and less a DSL.
  • closed vs. open: your spec is [:map [:x int?] [:y int?]]. with open schema, {:x 1 :y 2 :z 3} satisfies it. With closed schema, it fails since :z is not in schema.
1 Like