This library features support for fulfilling EQL queries via subscriptions.
The goals of implementing this were:
db->tree
and instead fulfill all queries with subscriptions.There are two pieces to have this work for your applicaiton.
;; Datalevin
space.matterandvoid.subscriptions.datalevin-eql
;; XTDB
space.matterandvoid.subscriptions.xtdb-eql
;; Fulcro
space.matterandvoid.subscriptions.fulcro-eql
After that you can execute EQL queries against your datasource.
Here is an example using datalevin as the data source.
(ns my-app.entry
(:require
[datalevin.core :as d]
[space.matterandvoid.subscriptions.core :as subs :refer [<sub]]
[space.matterandvoid.subscriptions.datalevin-eql :as datalevin.eql :refer [nc query-key xform-fn-key walk-fn-key]]))
(def schema
{:user/id {:db/valueType :db.type/keyword :db/unique :db.unique/identity}
:user/friends {:db/valueType :db.type/ref :db/cardinality :db.cardinality/many}
:user/name {:db/valueType :db.type/string :db/unique :db.unique/identity}
:bot/id {:db/valueType :db.type/keyword :db/unique :db.unique/identity}
:bot/name {:db/valueType :db.type/string :db/unique :db.unique/identity}})
(defonce conn (d/get-conn "/tmp/datalevin/mydb" schema))
(def user-comp (nc {:name ::user ;; the component's name
:query [:user/id :user/name {:user/friends '...}] ;; the component's query
:ident :user/id})) ;; the component's ident - the unique identifier property for this domain entity.
(def bot-comp (nc {:name ::bot
:query [:bot/id :bot/name]
:ident :bot/id}))
nc
is a naked fulcro component (no UI, just for normalizing and querying data), it is exported from the EQL namespaces for convenience. It is a wrapper of the
fulcro nc
function with a more uniform interface (taking only a hashmap). If fulcro is not on your classpath a stub
version
is used so that if you don't want to use fulcro you don't have to and the EQL queries feature will still work.
After you have your components declared, you register them, creating subscriptions for them that can fulfill EQL queries for them.
(run! datalevin.eql/register-component-subs! [user-comp bot-comp])
Now we transact some data to query:
(d/transact! conn
[{:user/id :user-7 :user/name "user 7"}
{:user/id :user-6 :user/name "user 6" :user/friends [[:user/id :user-7]]}
{:user/id :user-5 :user/name "user 5" :user/friends [[:user/id :user-6] [:user/id :user-7]]}
{:user/id :user-2 :user/name "user 2" :user/friends [[:user/id :user-2] -1 -3 [:user/id :user-5]]}
{:db/id -1 :user/id :user-1 :user/name "user 1" :user/friends [[:user/id :user-2]]}
{:user/id :user-4 :user/name "user 4" :user/friends [-3 [:user/id :user-4]]}
{:db/id -3 :user/id :user-3 :user/name "user 3" :user/friends [[:user/id :user-2] [:user/id :user-4]]}
{:bot/id :bot-1 :bot/name "bot 1"}
{:db/id -10 :user/id :user-10 :user/name "user 10" :user/friends [-10 -11]}
{:db/id -11 :user/id :user-11 :user/name "user 11" :user/friends [-10 -12]}
{:db/id -12 :user/id :user-12 :user/name "user 12" :user/friends [-11 -12]}])
In order to have a uniform API for all subscription data sources, your db must be wrapped in an atom, although there is currently no reactivitiy for JVM Clojure subscriptions.
(defonce db_ (atom (d/db conn)))
And now we can run arbitrary EQL queries!
The syntax is as follows, for each registered domain entity there will be a subscription with the name of the component
(the :name
key passed to nc
), which is ::user
in this example.
All subscriptions have the shape of a 2-tuple containing a keyword in the first position and a hashmap in the second
position.
The hashmap is open, you can put anything you want there.
The EQL implementation makes use of the hashmap to pass your EQL query under the well-known key exported by the
library query-key
imported in the above namespace :require
form.
Here we ask for the user with :user/id
:user-1
, pulling three attributes.
The 0
in the recursive position means do not resolve any nested references, just return them as pointers.
(<sub db_ [::user {:user/id :user-1 query-key [:user/name :user/id {:user/friends 0}]}])
; =>
{:user/name "user 1", :user/id :user-1, :user/friends [{:db/id 4}]}
;; expand one more level:
(<sub db_ [::user {:user/id :user-1 query-key [:user/name :user/id {:user/friends 1}]}])
; =>
{:user/name "user 1",
:user/id :user-1,
:user/friends [{:user/name "user 2", :user/id :user-2, :user/friends [{:db/id 4} {:db/id 6} {:db/id 3} {:db/id 5}]}]}
A common query desire is pulling only some of the nodes in a nested fashion based on your application logic, as well as transforming those nodes in some way, recursively. The library has support for both of these use cases.
Here we pull 4 levels of friends and also perform a transformation on each friend, at each level.
The transformation function can return anything (which means that if you, for example, replace the :user/friends
key
in the transform function, the recursion will stop).
The EQL query is provided under the query-key
, which must be data, so we use EQL params support on the recursion node
(a list containing the key, :user/friends
in this example, and a hashmap of parameters.) Then provide the
implementation
in the arguments map that the subscription will use.
(<sub db_ [::user {`upper-case-name (fn [e] (update e :user/name clojure.string/upper-case))
:user/id :user-1
query-key [:user/name :user/id {(list :user/friends {xform-fn-key `upper-case-name}) 4}]}])
;; =>
{:user/name "user 1",
:user/id :user-1,
:user/friends [{:user/name "USER 2",
:user/id :user-2,
:user/friends [{:user/name "USER 2",
:user/id :user-2,
:user/friends #{{:db/id 4} {:db/id 6} {:db/id 3} {:db/id 5}}}
{:user/name "USER 3",
:user/id :user-3,
:user/friends [{:user/name "USER 4",
:user/id :user-4,
:user/friends [{:user/name "USER 4",
:user/id :user-4,
:user/friends [{:db/id 7} {:db/id 6}]}
{:user/name "USER 3",
:user/id :user-3,
:user/friends [{:db/id 7} {:db/id 4}]}]}
{:user/name "USER 2",
:user/id :user-2,
:user/friends #{{:db/id 4} {:db/id 6} {:db/id 3} {:db/id 5}}}]}
{:user/name "USER 5",
:user/id :user-5,
:user/friends [{:user/name "USER 7", :user/id :user-7}
{:user/name "USER 6",
:user/id :user-6,
:user/friends [{:user/name "USER 7", :user/id :user-7}]}]}
{:user/name "USER 1", :user/id :user-1, :user/friends #{{:db/id 4}}}]}]}
Note that the starting node is not transformed - the transform applies only to the nodes in the relationship. This lets you apply a different transform to each entity relationship.
The transformation function takes an entity returned from an entity subscription and can return any value, in the above
example we upper-case the :user/name
attribute - This transform happens in a recursive fashion for any entities found
under the :user/friends
key.
If you want to control which nodes are recursively walked, pass the walk-fn-key
and convert the recursion to
unbounded (using: '...
).
(<sub db_ [::user {`upper-case-name (fn [e] (update e :user/name str/upper-case))
`keep-walking? (fn [e] (#{"user 1" "user 2"} (:user/name e)))
:user/id :user-1
query-key [:user/name :user/id {(list :user/friends {xform-fn-key `upper-case-name
walk-fn-key `keep-walking?}) '...}]}])
{:user/name "user 1",
:user/id :user-1, ; expanded
:user/friends [{:user/name "USER 2",
:user/id :user-2,
:user/friends [{:user/name "USER 3", :user/id :user-3, :user/friends #{{:db/id 13} {:db/id 10}}} ; not exanded
{:user/name "USER 1", :user/id :user-1, :user/friends #{{:db/id 10}}} ; expanded, but cycle so stop
{:user/name "USER 5", :user/id :user-5, :user/friends #{{:db/id 7} {:db/id 8}}} ; not expanded
{:user/name "USER 2",
:user/id :user-2, ; already expanded, cycle so stop walking
:user/friends #{{:db/id 12} {:db/id 11} {:db/id 9} {:db/id 10}}}]}]}
For the walking function, when the subscription sees unbounded recursion (the ...
) it checks for a symbol under
the walk-fn-key
key,
and uses that symbol to lookup the corresponding function in the paramaters hashmap provided to the subscription and
then invokes it
with the data found in the datasource under the corresponding key (:user/friends
in this example).
Based on the return value of that function the subscription will determine what to do next.
The currenlty supported return values and the semantics of those returns values are:
:stop
and :expand
:stop
is expected to be a collection of refs (normalized pointers for your database) which will be expanded as
an entity, but whose recursive property will not continue to be expanded.:expand
is expected to be a collection of refs (normalized pointers for your database) which will continue to be
recursively queried for and expanded as a tree.:stop
or :expand
keys they
will not be included in the
outputThis library also supports using functions as subscription values where there is no global registry.
The only difference to the above API is that you use the create-component-subs
function, which returns a subscription
function per component.
For the user component above:
(def user-sub (create-component-subs user-comp nil))
The nil
argument is to support components with joins, the subscription functions to fulfill the joins need to be
provided
as there is no global state.
For a made up example, say a user has many notes:
(def notes-sub (create-component-subs notes-comp nil))
(def user-sub (create-component-subs user-comp {:user/notes notes-sub}))
For union joins we need to provide another level of nesting.
Here's an example where a todo component has an author which can be either a bot
or a user
.
(def user-comp (nc {:query [:user/id :user/name {:user/friends '...}] :name ::user :ident :user/id}))
(def bot-comp (nc {:query [:bot/id :bot/name] :name ::bot :ident :bot/id}))
;; the author is a union component, you do not create a subscription for union components.
(def author-comp (nc {:query {:bot/id (get-query bot-comp) :user/id (get-query user-comp)} :name ::author}))
(def todo-comp (nc {:query [:todo/id :todo/text {:todo/author (get-query author-comp)}] :name ::todo :ident :todo/id}))
(def user-sub (create-component-subs user-comp nil))
(def bot-sub (create-component-subs bot-comp nil))
(def todo-sub (create-component-subs todo-comp {:todo/author
{:bot/id bot-sub :user/id user-sub}}))
The todo-sub
's joins map has a second level of nesting for the :todo/author
union. Based on the ref stored in the database
for a specific todo ([:bot/id :bot-id-1]
or [:user/id :user-id-1]
for example) the appropriate subscription will be used
to fulfill the rest of the query.
To subscribe to these you pass the function to subscribe
or <sub
functions - or invoke them directly.
Here was ask for the appropriate name, based on the type of the author found for the todo with id :todo-1
:
(<sub db_ [todo-sub {:todo/id :todo-1 query-key [{:todo/author {:bot/id [:bot/name]
:user/id [:user/name]}}]}])
;; or invoke directly:
(todo-sub db_ {:todo/id :todo-1 query-key [{:todo/author {:bot/id [:bot/name]
:user/id [:user/name]}}]})
which would return {:todo/author {:bot/name "bot"}}
if a bot ref is found and {:todo/author {:user/name "user"}}
if a user ref is found.
If you would like to add support for another datasource like doxa, or datascript for example, you simply need to fill in the IDataSource protocol
(pull requests are welcome!)
See the sources for examples:
The implementation assumes that entity IDs are unique across your entire database. This is mainly only a potential issue for usage with a fulcro DB because you can use a setup like:
{:person/id {1 {:person/id 1 :person/name "a person"}}
:comment/id {1 {:comment/id 1 :comment/text "FIRST COMMENT"
:comment/sub-comments [[:comment/id 2]]}
2 {:comment/id 2 :comment/text "SECOND COMMENT"}}}
In the implementation this is used to track cycles in recursive queries, thus the logic would be faulty because it would assume the two entities with id 1 are the same, when they are not.
In practice your applications should be using UUIDs or similar, so this shouldn't be an issue, but I'm mentioning it just in case.
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close