(enable-console-print!)
The defview
macro defines a view, which is a data structure that aggregates events and allows query over these events.
defview
four positional parameters:
defview-1
(defonce published1 (atom nil))
(reset! published1 nil)
(defview my-tweets [user]
[:tweetlog/tweeted user tweet])
The defined view is a function with the parameters defined in the view definition.
defview-2
(is (fn? my-tweets))
When called for the first time, the function will send a :reg
event with key according to the parameters, and return an empty sequence with a meta field :pending
indicating that the function should be consulted again later.
defview-3
(let [host {:pub (fn [ev]
(swap! published1 conj ev))
:sub (fn [key f])}
res (my-tweets host "alice")]
(is (= res []))
(is (= (-> res meta :pending) true))
(is (= @published1 [{:kind :reg
:name "tweetlog/tweeted"
:key "alice"
:get-existing true}])))
An optional keyword parameter :store-in
takes an atom and initially reset!
s is to an empty map.
defview-4
(defonce ps2 (ax/pubsub :name))
(defonce published2 (atom nil))
(reset! published2 nil)
(defonce my-atom2 (atom nil))
(defview my-tweets2 [user]
[:tweetlog/tweeted user tweet]
:store-in my-atom2)
(let [host {:pub (fn [ev])
:sub (fn [key f])}]
(my-tweets2 host "foo"))
(is (map? @my-atom2))
When events are received from the host they are placed in this map. This is a two-level map, where the first level of keys corresponds to the view argument vector, mapping a set of results to each combination of arguments. The second level of keys consists of the received events, with the :ts
and :change
fields omitted. The values are the accumulated :change
of all matching events.
defview-5
(let [host {:sub (:sub ps2)
:pub #(swap! published2 conj %)
:time (constantly 12345)
:identity (atom "alice")}]
(reset! my-atom2 {})
(is (= (my-tweets2 host "alice") [])) ;; Make the inital call that returns an empty collection
((:pub ps2)
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["hello"]
:ts 1000
:change 1
:readers #{}
:writers #{"alice"}})
((:pub ps2)
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["world"]
:ts 2000
:change 1
:readers #{}
:writers #{"alice"}})
((:pub ps2)
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["world"]
:ts 3000
:change 1
:readers #{}
:writers #{"alice"}})
(is (contains? @my-atom2 ["alice"]))
(is (= (get-in @my-atom2 [["alice"]
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["hello"]
:readers #{}
:writers #{"alice"}}]) 1))
(is (= (get-in @my-atom2 [["alice"]
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["world"]
:readers #{}
:writers #{"alice"}}]) 2)))
The view function returns, for a given combination of arguments, a collection of all data elements with a positive total count. In our case, both "hello" and "world" tweets are taken (in some order).
defview-6
(let [host {:sub (:sub ps2)
:pub #(swap! published2 conj %)
:time (constantly 12345)
:identity (atom "alice")}]
(is (= (count (my-tweets2 host "alice")) 2))
(is (= (-> (my-tweets2 host "alice")
meta :pending) false)))
Each element in the returned collection is a value map, a map in which the keys correspond to the symbols in the fact pattern provided in the view definition. In our case these are :user
and :tweet
. The values are their corresponding values in each event.
defview-7a
(let [host {:sub (:sub ps2)
:pub #(swap! published2 conj %)
:time (constantly 12345)
:identity (atom "alice")}]
(doseq [result (my-tweets2 host "alice")]
(is (= (:user result) "alice"))
(is (contains? #{"hello" "world"} (:tweet result)))))
Along with the data fields, each value map also contains the :-readers
and :-writers
of the corresponding events.
defview-7b
(let [host {:sub (:sub ps2)
:pub #(swap! published2 conj %)
:time (constantly 12345)
:identity (atom "alice")}]
(doseq [result (my-tweets2 host "alice")]
(is (= (:-readers result) #{}))
(is (= (:-writers result) #{"alice"}))))
Elements with zero or negative count values are not shown.
defview-8
(reset! my-atom2 {})
(swap! my-atom2 assoc-in [["alice"]
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["hello"]
:readers #{}
:writers #{"alice"}}] 0)
(swap! my-atom2 assoc-in [["alice"]
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["world"]
:readers #{}
:writers #{"alice"}}] -1)
(let [host {:sub (:sub ps2)
:pub #(swap! published2 conj %)
:time (constantly 12345)
:identity (atom "alice")}]
(is (= (count (my-tweets2 host "alice")) 0)))
To facilitate debugging, the atom in which the view's state is stored is exposed as the :state
meta field in the view function.
(fact defview-9
(defonce atom9 (atom nil))
(defview my-view9 [x]
[:foo/bar x y z]
:store-in ((constantly atom9)))
(is (= (-> my-view9 meta :state) atom9)))
A view provides functions that allow users to emit event for creating, updating and deleting facts.
For creating facts, a sequence returned by a view function has an :add
meta-field. This is a function that takes a value map as input, and emits a corresponding event with the following keys:
:kind
is always :fact
.:name
is the first element in the fact pattern, converted to string.:key
and :data
are derived from the value map.:ts
consults the host's :time
function.:change
is always 1.:writers
defaults to the set representing the user.:readers
defaults to the universal set.Parameters already given to the view function (e.g., :user "alice"
in the following example) should be omitted.defview-ev-1a
(reset! published2 [])
(let [host {:sub (:sub ps2)
:pub #(swap! published2 conj %)
:time (constantly 12345)
:identity (atom "alice")}
{:keys [add]} (meta (my-tweets2 host "alice"))]
(is (fn? add))
(add {:tweet "Hola!"})
(is (= @published2
[{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["Hola!"]
:ts 12345
:change 1
:writers #{"alice"}
:readers #{}}])))
The :readers
and :writers
sets in newly-added facts are controlled by the optional :readers
and :writers
keyword arguments in the view definition. These arguments are expressions that are evaluated for every invocation of add
. They can use any of the symbols in the fact pattern (which will evaluate to their corresponding value in the value map given to add
), and the symbol $user
, which represents the identity of the current user.
In the following example we define a view for tweetlog/follows
facts, indicating that one user follows another. We set the :writers
set to be the (following) user using both his or her system identity ($user
) and alias, and the :readers
set to contain the user being followed by alias.
defview-ev-1b
(defonce published-follows (atom nil))
(reset! published-follows nil)
(let [my-atom (atom nil)
ps (ax/pubsub :name)
host {:sub (:sub ps)
:pub #(reset! published-follows %)
:identity (atom "alice")
:time (constantly 2345)}]
(defview my-following [follower]
[:tweetlog/follows follower followee]
:writers #{$user [:tweetlog/has-alias follower]}
:readers #{[:tweetlog/has-alias followee]})
((:pub ps) {:kind :fact
:name "tweetlog/follows"
:key "alice123"
:data ["bob345"]
:ts 1234
:change 1
:writers #{"alice"}
:readers #{}})
(let [add (-> (my-following host "alice123")
meta :add)]
(add {:follower "alice123"
:followee "charlie678"}))
(is (= @published-follows {:kind :fact
:name "tweetlog/follows"
:key "alice123"
:data ["charlie678"]
:ts 2345
:change 1
:writers #{"alice" [:tweetlog/has-alias "alice123"]}
:readers #{[:tweetlog/has-alias "charlie678"]}})))
In value maps where the user is a writer, a :del!
entry contains a function that deletes the corresponding fact. It creates an event with all the same values, but with a new :ts
and a :change
value equal to the negative of the count value of the original event.
defview-ev-2
(defonce my-atom3 (atom nil))
(defonce published3 (atom nil))
(reset! published3 nil)
(defview my-tweets3 [user]
[:tweetlog/tweeted user tweet]
:store-in my-atom3)
(swap! my-atom3 assoc-in [["alice"]
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["hello"]
:readers #{}
:writers #{"alice"}}] 3)
(let [host {:pub #(swap! published3 conj %)
:sub (fn [key f])
:time (constantly 23456)}
valmap (first (my-tweets3 host "alice"))]
(is (fn? (:del! valmap)))
((:del! valmap))
(is (= @published3
[{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["hello"]
:ts 23456
:change -3
:readers #{}
:writers #{"alice"}}])))
A :swap!
function provides a way to update a value map. It takes a function (and optionally arguments) that is applied to the value map, and emits an atomic update event from the original state to the state reflected by the modified value map.
defview-ev-3
(defonce my-atom4 (atom nil))
(defonce published4 (atom nil))
(reset! published4 nil)
(defview my-tweets4 [user]
[:tweetlog/tweeted user tweet ts]
:store-in my-atom4)
(swap! my-atom4 assoc-in [["alice"]
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["hello" 12345]
:readers #{}
:writers #{"alice"}}] 3)
(let [host {:pub #(swap! published4 conj %)
:sub (fn [key f])
:time (constantly 34567)}
valmap (first (my-tweets4 host "alice"))]
(is (fn? (:swap! valmap)))
((:swap! valmap) assoc :tweet "world")
(is (= @published4
[{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:removed ["hello" 12345]
:data ["world" 12345]
:ts 34567
:change 3
:readers #{}
:writers #{"alice"}}])))
Views can define client-side filtering for facts.
Imagine we are only interested in tweets that contain a hash-tag, i.e., tweets that match the regular expression #".*#[a-zA-Z0-9]+.*"
. We define such filtering using an optional :when
key in defview
.
defview-filt
(defonce my-atom5 (atom nil))
(defview hashtags-only [user]
[:tweetlog/tweeted user tweet]
:store-in my-atom5
:when (re-matches #".*#[a-zA-Z0-9]+.*" tweet))
(reset! my-atom5 {})
(let [ps5 (ax/pubsub :name)
host {:sub (:sub ps5)
:pub (constantly nil)}]
(is (= (hashtags-only host "alice") [])) ;; Initial call to view function
((:pub ps5)
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["No hashtags here..."]
:ts 1000
:change 1
:readers #{}
:writers #{"alice"}})
((:pub ps5)
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["This one hash #hashtags..."]
:ts 2000
:change 1
:readers #{}
:writers #{"alice"}})
(is (= (count (@my-atom5 ["alice"])) 1)))
The optional keyword argument :order-by
directs the view function to sort the elements it returns according to the given expression. The expression can rely on symbols from the fact pattern, and must result in a comparable expression.
defview-sort
(defonce my-atom6 (atom nil))
(defview my-sorted-tweets [user]
[:tweetlog/tweeted user tweet timestamp]
:store-in my-atom6
:order-by (- timestamp) ;; Later tweets first
)
(reset! my-atom6 {})
(swap! my-atom6 assoc-in
[["alice"]
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["my first tweet" 1000]
:readers #{}
:writers #{"alice"}}] 1)
(swap! my-atom6 assoc-in
[["alice"]
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["my second tweet" 2000]
:readers #{}
:writers #{"alice"}}] 1)
(swap! my-atom6 assoc-in
[["alice"]
{:kind :fact
:name "tweetlog/tweeted"
:key "alice"
:data ["my third tweet" 3000]
:readers #{}
:writers #{"alice"}}] 1)
(let [host {:sub (fn [key f])}
tweets-in-order (for [result (my-sorted-tweets host "alice")]
(:tweet result))]
(is (= tweets-in-order ["my third tweet"
"my second tweet"
"my first tweet"])))
defquery
is similar to defview, but instead of querying for facts, it queries for results contributed by clauses.
This macro's structure is similar to defview
, with one exception – instead of providing a fact pattern, we provide a predicate pattern, of the form:
[:some/name input argument -> output arguments]
defquery-1
(defonce unique7 (atom nil))
(reset! unique7 0)
(defonce ps7 (ax/pubsub :name))
(defonce published7 (atom nil))
(reset! published7 [])
(defonce host7 {:sub (:sub ps7)
:pub #(swap! published7 conj %)
:identity (atom "alice")
:uuid (fn []
(str "SOMEUUID" (swap! unique7 inc)))
:time (constantly 12345)})
(defonce my-atom7 (atom nil))
(defquery my-query7 [user]
[:tweetlog/timeline user -> author tweet]
:store-in ((constantly my-atom7)) ;; Optional
)
The optional :store-in
arguement provides an atom in which received events are stored. The structure is similar to that used by defview
.
defquery-2
(is (map? @my-atom7))
As in defview
, defquery
defines a function. When called, two events are emitted:
:reg
event for registering to results, and:fact
event to make the query.In such a case the function returns an empty sequence with a :pending
meta field set to true
.defquery-3
(reset! my-atom7 {})
(let [result (my-query7 host7 "alice")]
(is (= result []))
(is (= (-> result meta :pending) true)))
(is (= @published7
[{:kind :reg
:name "tweetlog/timeline!"
:key "SOMEUUID1"}
{:kind :fact
:name "tweetlog/timeline?"
:key "SOMEUUID1"
:data ["alice"]
:ts 12345
:change 1
:writers #{"alice"}
:readers #{"alice"}}]))
Incoming events that carry query results update the map in this atom. The main difference between this and what defview
does is that while the events that defview
receives contain all the data necessary for calculating both the keys and the values, The results here do not contain the information for the key (the function's arguments). Instead, the event contains a unique ID, which is mapped by defquery
to input arguments.
defquery-4
(reset! my-atom7 {})
((:pub ps7) {:kind :fact
:name "tweetlog/timeline!"
:key "SOMEUUID1"
:data ["bob" "hi there"]
:ts 23456
:change 1
:writers #{"XXYY"}
:readers #{}})
(is (= (get-in @my-atom7
[["alice"]
{:kind :fact
:name "tweetlog/timeline!"
:key "SOMEUUID1"
:data ["bob" "hi there"]
:writers #{"XXYY"}
:readers #{}}]) 1))
With results in place, the query function returns a (non-:pending
) list of value maps. This time, the value maps capture the query's output parameters only.
defquery-5
(let [result (my-query7 host7 "alice")]
(is (= (-> result meta :pending) false))
(is (= result
[{:author "bob"
:tweet "hi there"}])) )
As with defview
, results with a count value of zero or under are omitted.
defquery-6
(reset! my-atom7 {})
(swap! my-atom7 assoc-in [["alice"]
{:kind :fact
:name "tweetlog/timeline!"
:key "SOMEUUID1"
:data ["bob" "hi there"]
:writers #{"XXYY"}
:readers #{}}] 0)
(swap! my-atom7 assoc-in [["alice"]
{:kind :fact
:name "tweetlog/timeline!"
:key "SOMEUUID1"
:data ["bob" "bye there"]
:writers #{"XXYY"}
:readers #{}}] -1)
(is (= (my-query7 host7 "alice") []))
As with defview, the query function has a meta-field holding the atom containing the state.
defquery-7
(is (= (-> my-query7 meta :state) my-atom7))
Filtering and sorting work exactly as with defview
.
defquery-filt
(defonce my-atom8 (atom nil))
(defquery tweets-by-authors-that-begin-in-b [user]
[:tweetlog/timeline user -> author tweet]
:store-in my-atom8
:when (str/starts-with? author "b")
:order-by tweet)
(let [ps8 (ax/pubsub :name)
host8 {:pub (fn [ev])
:sub (:sub ps8)
:identity (atom "alice")
:uuid (constantly "SOMEUUID")
:time (constantly 12345)}]
(tweets-by-authors-that-begin-in-b host8 "alice") ;; Start listenning...
((:pub ps8)
{:kind :fact
:name "tweetlog/timeline!"
:key "SOMEUUID"
:data ["bob" "D"]
:ts 1000
:change 1
:writers #{"XXYY"}
:readers #{}})
((:pub ps8)
{:kind :fact
:name "tweetlog/timeline!"
:key "SOMEUUID"
:data ["charlie" "C"] ;; Dropped
:ts 2000
:change 1
:writers #{"XXYY"}
:readers #{}})
((:pub ps8)
{:kind :fact
:name "tweetlog/timeline!"
:key "SOMEUUID"
:data ["boaz" "B"]
:ts 3000
:change 1
:writers #{"XXYY"}
:readers #{}})
((:pub ps8)
{:kind :fact
:name "tweetlog/timeline!"
:key "SOMEUUID"
:data ["bob" "A"]
:ts 4000
:change 1
:writers #{"XXYY"}
:readers #{}})
(let [results (tweets-by-authors-that-begin-in-b host8 "alice")]
(is (= (count results) 3))
(is (= (->> results
(map :tweet))
["A" "B" "D"]))))