Biff uses XTDB for the database. It's OK if you haven't used XTDB before, but you may want to peruse some of the learning resources at least.
The system map (and by extension, incoming requests) includes a
:biff.xtdb/node
key which can be used to submit transactions:
(require '[xtdb.api :as xt])
(defn send-message [{:keys [biff.xtdb/node session params] :as ctx}]
(xt/submit-tx node
[[::xt/put {:xt/id (java.util.UUID/randomUUID)
:msg/user (:uid session)
:msg/text (:text params)
:msg/sent-at (java.util.Date.)}]])
...)
Biff also provides a higher-level wrapper over xtdb.api/submit-tx
. It lets
you specify document types from your schema. If the document you're trying to
write doesn't match its respective schema, the transaction will fail. In
addition, Biff will call xt/await-tx
on the result, so you can read your
writes.
(require '[com.biffweb :as biff])
(defn send-message [{:keys [session params] :as ctx}]
(biff/submit-tx
;; select-keys is for illustration. Normally you would just pass in ctx.
(select-keys ctx [:biff.xtdb/node :biff/malli-opts])
[{:db/doc-type :message
:msg/user (:uid session)
:msg/text (:text params)
:msg/sent-at (java.util.Date.)}])
...)
If you don't set :xt/id
, Biff will use (java.util.UUID/randomUUID)
as the default value.
The default operation is :xtdb.api/put
.
Biff transactions can also include regular XT operations. Any element of the transaction that isn't a map will be assumed to be an XT operation:
(biff/submit-tx sys
[{:db/doc-type :user
:db/op :merge
:xt/id (or (biff/lookup-id db :user/email email)
(java.util.UUID/randomUUID))
:user/email email}
[::xt/fn :biff/ensure-unique {:user/email email}]])
If there is contention (e.g. if two concurrent requests attempt to write to update the same document, or if a transaction function fails), the transaction will be retried up to three times. If you pass a function that returns a transaction, then the function will be called again before each retry:
(biff/submit-tx ctx
(fn [{:keys [biff/db]}]
[{:db/doc-type :user
:db/op :merge
:xt/id (or (biff/lookup-id db :user/email email)
(java.util.UUID/randomUUID))
:user/email email}
[::xt/fn :biff/ensure-unique {:user/email email}]]))
As a convenience, any occurrences of :db/now
will be replaced with (java.util.Date.)
:
(defn send-message [{:keys [session params] :as ctx}]
(biff/submit-tx ctx
[{:db/doc-type :message
:msg/user (:uid session)
:msg/text (:text params)
:msg/sent-at :db/now}])
...)
Similarly, any keywords with a namespace of db.id
will be replaced with random UUIDs:
(biff/submit-tx sys
[{:db/doc-type :user
:xt/id :db.id/bob
:user/email "bob@example.com"}
{:db/doc-type :msg
:msg/user :db.id/bob
:msg/text "I am bob"}
{:db/doc-type :user
:xt/id :db.id/alice
:user/email "alice@example.com"}
{:db/doc-type :msg
:msg/user :db.id/alice
:msg/text "I am not bob"}])
You can delete a document by setting :db/op :delete
:
(defn delete-message [{:keys [params] :as ctx}]
(biff/submit-tx ctx
[{:xt/id (java.util.UUID/fromString (:msg-id params))
:db/op :delete}])
...)
If you set :db/op :update
or :db/op :merge
, the document will be merged
into an existing document if it exists. The difference is that :update
will
cause the transaction to fail if the document doesn't already exist, while :merge
will create the document.
(defn set-foo [{:keys [session params] :as ctx}]
(biff/submit-tx ctx
[{:db/op :update
:db/doc-type :user
:xt/id (:uid session)
:user/foo (:foo params)}])
...)
There is also a :db/op :create
operation which is the opposite of
:db/op :update
: the transaction will fail if the document does exist.
Biff uses :xtdb.api/match
operations to ensure that concurrent
merge/update operations don't get overwritten. If the match fails, the
transaction will be retried up to three times.
You can use :db.op/upsert
to update a document with the given attributes,
or create a new one if doesn't exist yet:
(biff/submit-tx sys
[{:db/doc-type :user
:db.op/upsert {:user/email "hello@example.com"}
:user/joined-at :db/now}])
If the document is created, :xt/id
will be set to (random-uuid)
. A transaction function is
used to make sure the operation is atomic. Besides that, upsert operations work the same as
:db/op :merge
operations.
Note: You must have installed the :biff/ensure-unique
transaction
function for this to work. This is done by default in new projects. See
com.biffweb/tx-fns
.
Some operations can be used on a per-attribute basis. Often these operations use the attribute's previous value, along with new values you provide, to determine what the final value should be.
Use :db/union
to coerce the previous value to a set and insert new values
with clojure.set/union
:
[{:db/op :update
:db/doc-type :post
:xt/id #uuid "..."
:post/tags [:db/union "clojure" "almonds"]}]
Use :db/difference
to do the opposite:
[{:db/op :update
:db/doc-type :post
:xt/id #uuid "..."
:post/tags [:db/difference "almonds"]}]
Add to or subtract from numbers with :db/add
:
[{:db/op :update
:db/doc-type :account
:xt/id #uuid "..."
:account/balance [:db/add -50]}]
Use :db/default
to set a value only if the existing document doesn't
already contain the attribute:
[{:db/op :update
:db/doc-type :user
:xt/id #uuid "..."
:user/favorite-color [:db/default :yellow]}]
Use :db/dissoc
to remove an attribute:
[{:db/op :update
:db/doc-type :user
:xt/id #uuid "..."
:user/foo :db/dissoc}]
Use :db/unique
to ensure that no other document has the same value for the given attribute:
[{:db/doc-type :user
:xt/id #uuid "..."
:user/handle [:db/unique "hunter2"]}]
Note: You must have installed the :biff/ensure-unique
transaction
function for this to work. This is done by default in new projects. See
com.biffweb/tx-fns
.
:db/lookup
Note: :db/lookup
is deprecated. It's recommended to use :db.op/upsert
instead, as is done in the starter project.
Finally, you can use :db/lookup
to enforce uniqueness constraints on attributes
other than :xt/id
:
[{:db/doc-type :user
:xt/id [:db/lookup {:user/email "hello@example.com"}]}]
This will use a separate "lookup document" that, if the user has been created already, will look like this:
{:xt/id {:user/email "hello@example.com"}
:db/owned-by ...}
where ...
is a document ID. If the document doesn't exist, the ID will be (java.util.UUID/randomUUID)
,
unless you pass in a different default ID with :db/lookup
:
[{:db/doc-type :user
:xt/id [:db/lookup {:user/email "hello@example.com"} #uuid "..."]}]
If the first value passed along with :db/lookup
is a map, it will get merged
in to the document. So our entire transaction would end up looking like this, assuming
the user document doesn't already exist:
[{:db/doc-type :user
:xt/id [:db/lookup {:user/email "hello@example.com"}]}]
;; =>
[[:xtdb.api/put {:xt/id #uuid "abc123"
:user/email "hello@example.com"}]
[:xtdb.api/match {:user/email "hello@example.com"} nil]
[:xtdb.api/put {:xt/id {:user/email "hello@example.com"}
:db/owned-by #uuid "abc123"}]]
See also: