A library to enable traversal of in-memory graph-like data structures using Clojure(Script) map protocols.
(require '[cjsauer.joinery :refer [joined-map]])
;; Assume you have a local normalized data source.
;; joinery includes support for "table-like" sources out of the box:
(def db
{:person/id {1 {:person/name "Calvin"
:person/friends [[:person/id 2]]
:person/pet [:pet/id 1]}
2 {:person/name "Derek"
:person/friends [[:person/id 1]]}}
:pet/id {1 {:pet/name "Malcolm"
:pet/species :dog
:pet/owner [:person/id 1]}}})
;; Create a joined-map interface to the db
(def jm (joined-map db))
;; Use it like a normal map, and observe "joins" resolved on-demand
(get-in jm [:person/id 1 :person/pet])
;;=> #:pet{:name "Malcolm", :owner [:person/id 1], :species :dog}
;; Cardinality-many joins are resolved recursively
(get-in jm [:person/id 1 :person/friends])
;; => [#:person{:name "Derek", :friends [[:person/id 1]]}]
;; Cycles are possible
(get-in jm [:person/id 1 :person/pet :pet/owner])
;; => #:person{:name "Calvin", :friends [[:person/id 2]], :pet [:pet/id 1]}
;; Most of the expected map functions are implemented
;; assoc, dissoc, find, seq, reduce, etc...
(def jm' (assoc-in jm [:person/id 1 :person/best-friend] [:pet/id 1]))
(get-in jm' [:person/id 1])
;; => #:person{:name "Calvin", :best-friend [:pet/id 1], ...}
;; Let's follow the newly added edge
(get-in jm' [:person/id 1 :person/best-friend])
;; => #:pet{:name "Malcolm", :owner [:person/id 1], :species :dog}
The joined-map
constructor accepts a couple more helpful options that provide
additional means of customization. Firstly, by default, joined-map
will use
the provided db
as the starting point of traversal. One can optionally provide
a "starting entity" that will become the "current" value of the joined map:
(def db
{:person/id {1 {:person/name "Calvin"
:person/friends [[:person/id 2]]
:person/pet [:pet/id 1]}
2 {:person/name "Derek"
:person/friends [[:person/id 1]]}}
:pet/id {1 {:pet/name "Malcolm"
:pet/species :dog
:pet/owner [:person/id 1]}}})
;; Use the db as the backing source, but start at a different entity
(def jm (joined-map db {:ui.selected/user [:person/id 1]}))
jm
;; => #:ui.selected{:user [:person/id 1]}
;; Joins are resolved just as before
(:ui.selected/user jm)
;; => #:person{:friends [[:person/id 2]], :name "Calvin", :pet [:pet/id 1]}
In addition, one can customize the functionality of joins by implementing the
Joinery
protocol. Here is the default table-join implementation that ships
with joinery
:
(deftype TableIdentJoinery []
Joinery
(is-join? [_ v] (and (vector? v)
(= 2 (count v))
(keyword? (first v))))
(join [_ table v] (get-in table v)))
;; Create a joined-map using a specific Joinery implementation:
(joined-map db entity (TableIdentJoinery.))
We can see above that the protocol is quite simple. We need to provide two things: how to identify what values should be treated as "links", and, given we've reached one of these links, how do we "resolve" it. With these two functions, we can obtain a map-like interface to a myriad of different normalized sources.
joinery
came about while experimenting with the latest Clojure trend of
in-memory databases, namely:
- *Pyramid's normalized structure
- *Fulcro's app state
- Asami's
entity
function - DataScript's
entity
function - Datomic's
entity
function
While not a database per se, Pathom3's Smart Map
is another great source of inspiration. Pathom's scope is more broad in that smart
map access can trigger arbitrary code to run (even network access), while joinery
is mainly focused on local data structures only.
* joinery will work out of the box with these libraries
Run the project's tests:
$ clojure -T:build test
Run the project's CI pipeline and build a JAR:
$ clojure -T:build ci
This will produce an updated pom.xml
file with synchronized dependencies
inside the META-INF
directory inside target/classes
and the JAR in target
.
You can update the version (and SCM tag) information in generated pom.xml
by
updating build.clj
.
Install it locally (requires the ci
task be run first):
$ clojure -T:build install
Deploy it to Clojars -- needs CLOJARS_USERNAME
and CLOJARS_PASSWORD
environment
variables (requires the ci
task be run first):
$ clojure -T:build deploy
Copyright © 2021 Calvin Sauer
EPLv1.0 is just the default for projects generated by clj-new
: you are not
required to open source this project, nor are you required to use EPLv1.0!
Feel free to remove or change the LICENSE
file and remove or update this
section of the README.md
file!
Distributed under the Eclipse Public License version 1.0.