-
Notifications
You must be signed in to change notification settings - Fork 2
Graph Queries
There is a single function reflet.core/reg-pull
for defining
reactive graph queries. This function supports a subset of the
EQL spec, with union
queries replaced by the Reflet's polymorphic descriptions.
It also supports a couple of other extensions beyond EQL. Much of this
will be familiar to anyone who has used Datomic pull syntax.
To summarize, reg-pull
supports the following operations:
- Select an Attribute Set
- Joins and Nested Data
- Select a Single Attribute
- Select a Link Entry
- Wildcard Queries
- Infinite and Limited Recursive Queries
- Side effects
- Result Functions
- Non-reactive Queries in Event Handlers
Recall the graph music catalog from the Multi Model DB document:
{::db/data
{[:system/uuid #uuid "a"] {:system/uuid #uuid "a"
:kr/name "Miles Davis"
:kr.artist/albums #{[:system/uuid #uuid "b"]}}
[:system/uuid #uuid "b"] {:system/uuid #uuid "b"
:kr/name "Miles Smiles"
:kr.album/artist [:system/uuid #uuid "a"]
:kr.album/tracks [[:system/uuid #uuid "t1"]
[:system/uuid #uuid "t2"]]}
[:system/uuid #uuid "c"] {:system/uuid #uuid "c"
:kr/name "Wayne Shorter"}
[:system/uuid #uuid "t1"] {:system/uuid #uuid "t1"
:kr/name "Orbits"
:kr.track/artist [:system/uuid #uuid "c"]
:kr.track/album [:system/uuid #uuid "b"]
:kr.track/duration 281
:kr/uri "/audio/Miles Smiles - Orbits.mp3"}
[:system/uuid #uuid "t2"] {:system/uuid #uuid "t2"
:kr/name "Footprints"
:kr.track/artist [:system/uuid #uuid "c"]
:kr.track/album [:system/uuid #uuid "b"]
:kr.track/duration 596
:kr/uri "/audio/Miles Smiles - Footprints.mp3"}}}
The following query selects 4 attributes from the album specified by
album-ref
:
(f/reg-pull ::album
(fn [album-ref]
[[:system/uuid
:kr/name
:kr.album/artist
:kr.album/tracks]
album-ref]))
The reg-pull
handler always returns a vector of one or two
elements. The first element is always a pull spec. Here it is a vector
of 4 attributes. The second element, if provided, is the root entity
reference from which the query begins its data graph traversal.
(f/reg-pull ::query
(fn [root-ref]
[pull-spec
root-ref]))
Besides the query syntax, there are two other important differences from regular re-frame subscriptions:
-
The query handler does not depend on the db value. Instead it receives one or more arguments, usually references, which are used to construct the query.
-
The body of the query handler returns a declarative query expression, unlike a re-frame subscription handler which imperatively computes the query result from its data dependencies.
This has a number of benefits:
-
How often a query runs is now an implementation detail. With regular re-frame subscriptions, each time you write a handler, you have to explicitly choose the reactive dependency on all or part of the data to get the most efficient query. Did you choose the most specific subpath in the
db
? Is your subpath too general? You must design every handler correctly, or it will run more than it should. Withreg-pull
, the implementation decides when queries are run, so you can't make a mistake. -
The declarative pull specification is a visual reflection of the shape of your data: it is self-describing. With a regular re-frame subscription handler, you often have to parse imperative code to infer the shape of the data.
With your query defined, you can subscribe to the result in your components:
(defn component
[{:keys [album-ref]
:as props}]
(let [album @(f/sub [::album album-ref])]
[:div
[:div (:kr/name album)]
...]))
Recall reflet.core/sub
is just an alias for
re-frame.core/subscribe
. Subscribing to reg-pull
will return a
regular re-frame subscription object, where all the regular
subscription semantics, like caching and automatic disposal, apply.
The value returned by the dereferenced example subscription is:
{:system/uuid #uuid "b"
:kr/name "Miles Smiles"
:kr.album/artist [:system/uuid #uuid "a"]
:kr.album/tracks [[:system/uuid #uuid "t1"]
[:system/uuid #uuid "t2"]]}
As we can see, the above query spec did not traverse beyond the graph joins. To do so, we introduce a join expression to the pull syntax:
(f/reg-pull ::album
(fn [album-ref]
[[:system/uuid
:kr/name
{:kr.album/artist [:kr/name]}
{:kr.album/tracks [:system/uuid
:kr/name
:kr.track/duration]}]
album-ref]))
Here, the joins at both :kr.album/artist
and :kr.album/track
are
resolved, and the associated track data is returned in denormalized
form:
{:system/uuid #uuid "b"
:kr/name "Miles Smiles"
:kr.album/artist {:kr/name "Miles Davis"}
:kr.album/tracks [{:system/uuid #uuid "t1"
:kr/name "Orbits"
:kr.track/duration 281}
{:system/uuid #uuid "t2"
:kr/name "Footprints"
:kr.track/duration 281}]}
Note that the cardinality and type of the join attributes
:kr.album/artist
and :kr.album/tracks
is preserved with respect to
the input data. The album had a single artist, and a vector of
tracks. Remember there is no DB schema, so cardinality and type are
runtime dependent.
Quite often you only want a single attribute value from your root
entity. This is not part of EQL, but because it is so common, Reflet
provides an extension. To get just the name of the above album, use a
single attribute :kr/name
instead of a vector as the top level pull
spec:
(f/reg-pull ::album
(fn [album-ref]
[:kr/name album-ref]))
This will will return:
"Miles Smiles"
It also works with joins. To return the :kr.album/tracks
of the
album just do:
(f/reg-pull ::album
(fn [album-ref]
[{:kr.album/tracks [:system/uuid
:kr/name
:kr.track/duration]}
album-ref]))
Which will return:
[{:system/uuid #uuid "t1"
:kr/name "Orbits"
:kr.track/duration 281}
{:system/uuid #uuid "t2"
:kr/name "Footprints"
:kr.track/duration 281}]
As always the cardinality and type of the data is preserved.
To summarize, the root pull spec is always either a:
- vector: select an attribute set
- keyword: select a single attribute
- map: select a single attribute but traverse the join
Recall the data from the link entry example in the Multi Model DB document:
{::db/data
{:active/user [:system/uuid #uuid "user"] ; <- cardinality one join
[:system/uuid #uuid "user"] {:system/uuid #uuid "user" ; <- merged in user
:kr/name "Name"
:kr/role "user"}}}
There is a natural extension to selecting a single attribute that applies to link entries. If you do not provide a root reference, then the db is taken as the start of the graph traversal, and then selecting a single attribute is effectively a link query:
(f/reg-pull ::active-user-link
(fn []
[:active/user])) ; <- single attribute with no root reference
The above will return the link entry at :active/user
:
[:system/uuid #uuid "user"]
As before, to actually traverse the join, use the join syntax with a single attribute selection, but this time exclude the root reference:
(f/reg-pull ::active-user-link
(fn []
[{:active/user ; <- single attribute /w join
[:system/uuid ; <- nested select of attribute set
:kr/name
:kr/role]}]))
; <- no root reference, so db is the "entity"
This will return:
{:system/uuid #uuid "user"
:kr/name "Name"
:kr/role "user"}
To pull all the attributes of an entity, use the quoted '*
symbol
in an attribute set:
(f/reg-pull ::album
(fn [album-ref]
[[:system/uuid
:kr/name
{:kr.album/label [:kr/name]}
{:kr.album/tracks ['*]}]
album-ref]))
The above will pull all the attributes of each track. You can also combine a wildcard with other keywords in the attribute set:
(f/reg-pull ::album
(fn [album-ref]
[[:system/uuid
:kr/name
{:kr.album/label [:kr/name]}
{:kr.album/tracks ['* {:kr.track/artist [:kr/name]}]}]
album-ref]))
The above will pull all of the attributes of each track, and
traverse the join of each :kr.track/artist
attribute to get the
artist name.
Sometimes you need to query a graph data model that represents some recursive, tree-like structure. Here, recursive queries can save a lot of boilerplate:
(f/reg-pull ::recursive-infinite
(fn [ref]
[[:system/uuid
:kr.node/name
:kr.node/description
{:kr.node/child '...}]
ref]))
When the quoted ellipsis '...
is used as the value in a join
expression, it indicates that the currently scoped attribute set
should be used to process all nested nodes in the traversal. Note that
only the current attribute set participates in the recursion:
(f/reg-pull ::recursive-infinite
(fn [ref]
[[:kr/higher
:kr/context
{:kr/root-nodes [:system/uuid
:kr.node/name
:kr.node/description
{:kr.node/child '...}]}]
ref]))
Here, the [:kr/higher :kr/context :kr/root-nodes]
attribute set is
not part of the pattern.
Of course the result and performance of this kind of recursive graph traversal very much depends on the shape of the data. Your query could potentially span the entire db, or even fail to terminate if there are loops in your graph structure.
So Reflet also provides a limited recursive query spec. Just replace
the ellipsis ...
with the maximum number of recursions you wish to
allow:
(f/reg-pull ::recursive-2
(fn [id]
[[:kr/name
:kr/description
{:kr/join 2}]
id]))
Here we allow at maximum two recursions before the query traversal stops.
While EQL supports mutations, the syntax is not flexible enough to support Reflet's extended query grammar. Instead, Reflet leverages EQL's parameterized query syntax, to implement query side-effects.
Any sub-expression within a pull structure can be wrapped with query
parameters (sub-expr params)
:
(f/reg-pull ::album
(fn [album-ref other-ref]
[[:kr/name
{:kr.album/tracks ([:system/uuid
:kr/name
:kr.track/duration]
{:id ::my-pull-fx-method
:my-param other-ref})}]
album-ref]))
Here, the sub-expression in the :kr.album/tracks
join has been
wrapped with a list ()
, and a map of parameters. These parameters
are parsed by the reflet.core/pull-fx
multimethod, which dispatches
on the :id
in the map. A warning will be issued if no handler has
been defined for the given :id
.
You can define your own pull-fx
handler:
(defmethod f/pull-fx ::my-pull-fx-method
[params context]
(let [{:keys [id my-param]} params
{:keys [db ref expr]} context]
(do-some-side-effect ...)))
The multimethod function accepts both the params
from the query
expression, as well as the query execution context
. The context
map will contain both a :ref
to the current node in the graph
traversal, the sub query expression :expr
that remains to be
evaluated, and the immutable value of the db against which the query
is being executed. For the ::album
query example above, the
:ref
would be an entity reference to a track entity, and :expr
would be:
[:system/uuid
:kr/name
:kr.track/duration]
If the :kr.album/tracks
join resolves to cardinality many tracks,
then a separate pull-fx
call is made for each track entity.
Using this approach, the body of the pull-fx
could then dispatch a
synchronization event, or some other side-effect, the details of which
are left up to user code.
But remember: the pull effects are called every time a query is run. So you should always keep them as simple as possible, ideally no more than dispatching a Re-frame event.
When you subscribe to reg-pull
, it returns a regular subscription
object. Just like other subscriptions, these can participate in
layer-3 Re-frame subscriptions:
(require '[re-frame.core :as f*]
'[reflet.core :as f])
(f/reg-pull ::track-duration
;; This single attribute query gets the track duration in numeric seconds
(fn [track-ref]
[:kr.track/duration track-ref]))
(f*/reg-sub
;; Layer-3 Re-frame sub
(fn [[_ track-ref]]
(f/sub [::track-duration track-ref]))
(fn [secs]
;; Convert numeric seconds to a HH:mm:ss string representation
(when (number? secs)
(->> (-> secs
(* 1000)
( js/Date.)
(.toISOString)
(.slice 11 19))
(drop-while #{\0 \:})
(apply str)))))
Because this kind of post-processing so common, Reflet allows you to
append a result function to the reg-pull
definition:
(f/reg-pull ::track-duration
(fn [track-ref]
[:kr.track/duration track-ref])
(fn [secs]
;; Convert numeric seconds to a HH:mm:ss string representation
(when (number? secs)
(->> (-> secs
(* 1000)
( js/Date.)
(.toISOString)
(.slice 11 19))
(drop-while #{\0 \:})
(apply str)))))
The main benefit of using a result function, aside from saving some boilerplate, is that they are traced along-side with their input queries in the Reflet debugger. Regular layer-3 subscriptions are not.
The graph query and the result function are run in separate reactions, and the combined pathway is returned and cached against the invoking subscription vector:
@(f/sub [::track-duration track-ref])
Which should return something like:
"4:31"
The first argument of the result function is always the result of the input query, but if you need it, the result function additionally accepts all of the arguments to the input query as context:
(f/reg-pull ::my-quer
(fn [ref arg1 arg2]
[[:attr-1 :attr-2 :attr-3]
ref])
(fn [result ref arg1 arg2] ; <- Result + args from the input sub
(do-something ...)))
Often you will want to query graph data in event handlers. Because you cannot use reactive subscriptions in event handlers, three non-reactive, pure query functions are provided for this purpose.
reflet.db/getn
and reflet.db/get-inn
return graph data, with
semantics similar to clojure.core/get
and
clojure.core/get-in
. Importantly, they do not resolve entity
references.
Assuming the following db:
{::db/data
{[:system/uuid #uuid "a"] {:system/uuid #uuid "a"
:kr/name "Miles Davis"
:kr.artist/albums #{[:system/uuid #uuid "b"]}}
[:system/uuid #uuid "b"] {:system/uuid #uuid "b"
:kr/name "Miles Smiles"
:kr.album/artist [:system/uuid #uuid "a"]
:kr.album/tracks [[:system/uuid #uuid "t1"]
[:system/uuid #uuid "t2"]]}}}
Then:
(getn db [:system/uuid #uuid "a"])
Returns the whole entity:
{:system/uuid #uuid "a"
:kr/name "Miles Davis"
:kr.artist/albums #{[:system/uuid #uuid "b"]}}
While:
(get-inn db [[:system/uuid #uuid "a"] :kr/name])
Returns just the value:
"Miles Davis"
Full pull semantics, excluding side-effects are available via the
non-reactive, pure reflet.db/pull
:
(db/pull db
[:system/uuid
:kr.generic/name
{:kr.artist/albums [:kr/name]}]
[:system/uuid #uuid "a"])
This would return:
{:system/uuid #uuid "a"
:kr/name "Miles Davis"
:kr.artist/albums #{{:kr/name "Miles Smiles"}}
Take care which attributes you select in joins when the join type is a cardinality many set.
Consider this example graph data containing a location map and some geo data points:
{[:system/uuid 1] {:kr/type :kr.type/map
:kr/title "Demo Location Map"
:kr.map/points #{[:system/uuid 2]
[:system/uuid 3]
[:system/uuid 4]}}
[:system/uuid 2] {:system/uuid 2
:kr/type :kr.type/point
:kr.geo/latitude 40
:kr.geo/latitude 140}
[:system/uuid 3] {:system/uuid 3
:kr/type :kr.type/point
:kr.geo/latitude 44
:kr.geo/latitude 141}
[:system/uuid 4] {:system/uuid 4
:kr/type :kr.type/point
:kr.geo/latitude 40
:kr.geo/latitude 140}}
Notice that the :kr.map/points
resolves to a set of entities. Also
notice that two of the referenced entities share the same type,
latitude, and longitude. A query that pulls the map and all its points
might look something like:
(f/reg-pull ::state
(fn [ref]
[[:kr/title
{:kr.map/points [:kr/type
:kr.geo/latitude
:kr.geo/longitude]}]
ref]))
However, as specified, this query would not produce a set of unique
point results. The two points [:system/uuid 2]
and [:system/uuid 4]
share all the pulled attributes, and by set logic would unify to
produce:
{:kr/title "Demo Map"
:kr.map/points #{{:kr/type :kr.type/point ; <- Both [:system/uuid 2] and [:system/uuid 4]
:kr.geo/latitude 40
:kr.geo/latitude 140}
{:kr/type :kr.type/point
:kr.geo/latitude 44
:kr.geo/latitude 141}}}
This is probably not the desired result, since we would expect three points.
The solution is to ensure the unique identifier is included in the join spec, even if the consuming code does not use it. In reality the unique ids are almost always useful as React keys. But more importantly this guarantees that the results do not unintentionally unify:
(f/reg-pull ::state
(fn [ref]
[[:kr/title
{:kr.map/points [:kr/type
:system/uuid ; <- unique attribute will prevent unification
:kr.geo/latitude
:kr.geo/longitude]}]
ref]))
{:kr/title "Demo Map"
:kr.map/points #{{:system/uuid 2
:kr/type :kr.type/point
:kr.geo/latitude 40
:kr.geo/latitude 140}
{:system/uuid 3
:kr/type :kr.type/point
:kr.geo/latitude 44
:kr.geo/latitude 141}
{:system/uuid 4
:kr/type :kr.type/point
:kr.geo/latitude 40
:kr.geo/latitude 140}}}
This same consideration does not apply to vectors or lists, because of the nature of the collection type.
Next: Polymorphic Descriptions
Home: Home