1. What is this?
  2. Getting Started
  3. Usage
    1. References
    2. Reading & Writing
    3. Transactions
    4. Batching
    5. Queries
    6. Using Local State Atom
    7. Authentication
  4. API
  5. Contributing
  6. Credits
  7. License

What is this?

Firemore is a clojurescript library for interacting with Google Firestore. Main features include.

  1. Automatic conversions between clojure maps and Firestore Documents.
  2. A channel based API for reading, writing, and observing Firestore Documents.
  3. A succinct syntax for doing queries upon Firestore Documents.
  4. A drop in Authentication Solution for managing your users.

Getting Started

Add the following to your dependencies in project.clj (lein) or build.boot (boot) to use firemore in a existing project.

Clojars Project

Usage

The following is a walkthrough of the main features of Firemore.

Preamble

Let us define a new namespace and import core.async. core.async is a library for writing "synchronous looking" code for asynchronous processes. Firemore code is fundamentally asynchronous as it is constantly syncing the state of documents between a client and a server.

(ns firemore.readme
  (:require
   [cljs.core.async :as async])
  (:require-macros
   [cljs.core.async.macros :refer [go-loop go]]))

You can completely ignore this next definition! The following code is beyond the scope of this introduction. This creates a function that allows for logging "within" the current frame of execution. Again, this is not necessary at all to proceed. Just squint and move on, let's get to the fun stuff!

(require '[cljs.pprint :as pprint])

(import '[goog.string StringBuffer])

(defn ->output-fx []
  (let [captured-container js/klipse-container
        a (atom [])]
    (fn [k v]
      (let [sb (StringBuffer.)]
        (binding [*out* (StringBufferWriter. sb)]
          (pprint/pprint v *out*)
          (swap! a conj [k (str sb)])
          (set!
            (.-innerHTML captured-container)
            (str "\nOUTPUT:\n\n" 
              (clojure.string/join
                "\n"
                (for [[k s] @a] (str "[" k "] ->\n" s))))))))))

(let [output (->output-fx)]
  (output "friend1" "I like you.")
  (output "friend2" {:how "are" :you 2 "day" "?"}))

:done

References

Read the documentation on documents, collections, and references (just the linked page). Go ahead. I'll wait.

As you read, a Firestore reference is a javascript object with a large number of functions attached to it. A Firemore reference is a vector of keywords and/or strings with length at least 1.

So, the following document reference in Firestore:

db.collection('users').doc('alovelace');

Becomes this in Firemore:

[:users "alovelace"]

Keywords (the symbols with the : prefixed on them) appear at first, third, fifth, ... position. Strings appear at second, fourth, sixth, ... position. The keywords specify the name of the collection. The strings specify the document within a collection.

A reference with even length refers to a document, while a reference with an odd length refers to a collection. So [:users] is a reference to the users collection. While [:users "alovelace"] is a reference to a document within the users collection.

Reading and Writing

Ok, let's get things rolling. The firemore library is going to allow us to read, write, and query documents in the Firestore database. So let's import it.

  (require '[firemore.core :as firemore])  

In your own application, you would initialize your firestore database. However, it is fine for now to use the default firestore database by specifying no arguments to ``initialize``.

(firemore/initialize)

I was thinking about Star Wars this morning, and I couldn't remember. Does Luke Skywalker have Force powers? Let's check!

Note: The following data exist at the [:characters "luke"] reference

{:first-name  "Luke"
 :last-name   "Skywalker"
 :force-user? true
 :hair-color  "blond"
 :occupation  "farmboy"}
(go
  (let [->output (->output-fx)
        luke-reference [:characters "luke"]
        luke (async/<! (firemore/get luke-reference))]
        
    (->output "luke" luke)
    
    (->output "force user?" (:force-user? luke))))
    
:done

That's right, he does have Force powers! Couldn't remember.

A firemore document is a regular clojure map. Firemore attempts to provide useful defaults for converting the JSON like Firestore document to a clojure like Firemore document.

Usage:
(get reference)

Returns a channel. If a document exist at reference, it will be put! upon the
channel. If no document exist at reference, then :firemore/no-document will be
put! on the channel. The channel will then be closed.

Note:
put! ->  clojure.core.async/put!

But what if I want to see Luke change over time? What if I want to observe Luke's Heroic Journey? Rather than getting Luke once, let's watch him through time.

The first value pulled from luke-chan is the current value at my-luke-reference. Every write!, merge! or delete! at my-luke-reference will result in the updated document being place on luke-chan.

Note that the my-luke-reference begins with [:users user-id]. This is because I have set up security rules so that you and only you may read and write to the location under users/<user-id> in the Firestore database. This is necessary because this database is being used by everyone currently reading this documentation. The security rule allows me to carve out a little place in the database for you to play without conflicting with others.

In case you haven't seen it before (async/<! ...) allows you to await the result of the ... expression. This is only a half truth, and the details are more nuanced (and interesting!). Note that (async/<! ...) can only be used within a (go ...) or (go-loop ...) expression. Let's squint again and move on, but later on you should definitely take some time to read about core.async.

(go
  (let [->output (->output-fx)
        user-id (async/<! (firemore/uid))
        luke-reference [:characters "luke"]
        luke (async/<! (firemore/get luke-reference))
        my-luke-reference [:users user-id :characters "luke"]]
        
    ;; Copy Luke so we have something to watch and modify; add an occupation
    (firemore/write! my-luke-reference (assoc luke :occupation "farmboy"))
  
    (let [luke-chan (firemore/watch my-luke-reference)]
      ;; Luke's journey starts off as a farmboy
      (->output "Teenage Luke" (async/<! luke-chan))

      ;; Changing Luke's adult occupation...
      (async/<! (firemore/write! my-luke-reference (assoc luke :occupation "jedi")))
      (->output "Adult Luke" (async/<! luke-chan))

      ;; Changing to Lukes final occupation after episode 7
      (async/<! (firemore/write! my-luke-reference (assoc luke :occupation "One with the Force")))
      (->output "Episode 8 Luke" (async/<! luke-chan))

      ;; Remove luke from the Firestore database
      (async/<! (firemore/delete! my-luke-reference))
      (->output "Deleted Luke" (async/<! luke-chan))

      ;; Remember to close the channel when you are done with it!
      (async/close! luke-chan))))
      
:done
Usage:
(watch reference)

Returns a channel. If a document exist at reference, it will be put! upon
the channel. If no document exist at reference, then :firemore/no-document will
be put! on the channel. As the document at reference is updated through
time, the channel will put! the newest value of the document (if it exist)
or :firemore/no-document (if it does not) upon the channel.

Important: close! the channel to clean up the state machine feeding this
channel. Failure to close the channel will result in a memory leak.

Note:
put! ->  clojure.core.async/put!
close! ->  clojure.core.async/close!
Usage:
(write! reference m)

Returns a channel. Overwrites the document at reference with m.  Iff an error
occurs when writing m to Firestore, then the error will be put! upon the
channel. The channel will then be closed.

Note:
put! ->  clojure.core.async/put!
Usage:
(merge! reference m)

Returns a channel. Updates (merges in novelty) the document at reference with m.
Iff an error occurs when writing m to Firestore, then the error will be put!
upon the channel. The channel will then be closed.

Note:
put! ->  clojure.core.async/put!
Usage:
(delete! reference)

Returns a channel. Iff an error occurs when deleting reference from Firestore,
then the error will be put! upon the channel. The channel will then be closed.

Note:
put! -> clojure.core.async/put!

Transactions

Firemore also supports transactions. Transactions allow for atomic reads and writes within the Firestore database. Let us read the value at two documents and write them to a third document. We are reading the midichlorian count from Padme & Anakin and writing the midichlorian count back to Luke. Note that we are using the third (optional) argument to all our Firestore read & write functions to specify the transaction trx.

(go
  (let [->output (->output-fx)
        user-id (async/<! (firemore/uid))]
    (->> 
     (fn [trx]
      (go
       (let [{anakin-midichlorians :midichlorian} (async/<! (firemore/get [:characters "anakin"] {:transaction trx})) ;; 27700
             {padme-midichlorians  :midichlorian} (async/<! (firemore/get [:characters "padme"] {:transaction trx})) ;; 4700
             midichlorians-average (/ (+ padme-midichlorians anakin-midichlorians) 2)] ;; 16200
       (firemore/write! [:users user-id :test "luke"] {:midichlorian midichlorians-average} {:transaction trx})
       (str "midichlorians count is " midichlorians-average))))
     firemore/transact!
     async/<!
     (->output "Transaction Result"))))
     
:done

Batching

Batching writes is supported. Batches allow for atomic (all or nothing) writes, updates, and deletions of Firestore documents.


(go
     (let [->output (->output-fx)
           user-id    (async/<! (firemore/uid))
           reference  [:users user-id :batch-test]
           [r1 r2 r3] (->> (repeatedly 3 random-uuid)
                           (map str)
                           (map #(conj reference %)))
           btx1       (firemore/create-batch)
           btx2       (firemore/create-batch)]
       (firemore/write! r1 {:value 1} {:batch btx1})
       (firemore/write! r2 {:value 2} {:batch btx1})
       (firemore/write! r3 {:value 3} {:batch btx1})
       (async/<! (firemore/commit-batch! btx1))
       (->output :first-batch-writes
         [(async/<! (firemore/get r1))
          (async/<! (firemore/get r2))    
          (async/<! (firemore/get r3))])
       (firemore/write!  r1 {:value 4} {:batch btx2})
       (firemore/merge!  r2 {:value 5} {:batch btx2})
       (firemore/delete! r3            {:batch btx2})
       (async/<! (firemore/commit-batch! btx2))
       (->output :second-batch-writes
         [(async/<! (firemore/get r1))
          (async/<! (firemore/get r2))    
          (async/<! (firemore/get r3))])))

:done

Queries

Note: The following data exist at the [:cities] reference

{"SF"  {:name "San Francisco"
        :state "CA"
        :country "USA"
        :capital false
        :population 860000
        :regions ["west_coast" "norcal"]}
 "LA"  {:name "Los Angeles"
        :state "CA"
        :country "USA"
        :capital false
        :population 3900000
        :regions ["west_coast" "socal"]}
 "DC"  {:name "Washington, D.C."
        :state nil
        :country "USA"
        :capital false
        :population 680000
        :regions ["east_coast"]}
 "TOK" {:name "Tokyo"
        :state nil
        :country "Japan"
        :capital false
        :population 9000000000
        :regions ["kantu" "honshu"]}
 "BJ"  {:name "Beijing"
        :state nil
        :country "China"
        :capital false
        :population 21500000
        :regions ["jingjinji" "hebei"]}}

First read the documentation on queries. Firestore queries are built from a collection reference. Firemore queries are built by adding a query map to the end of the reference vector.

So this in Firestore

db.collection("cities").where("state", "==", "CA").where("population", "<", 1000000);

Becomes this in Firemore

[:cities {:where [[:state "==" "CA"] 
                  [:population "<" 1000000]]}]
(go
  (let [->output (->output-fx)
        cities (firemore/get [:cities {:where [[:state "==" "CA"] 
                                               [:population "<" 1000000]]}])]
    (->output "cities" (async/<! cities))))

:done

Containment

array-contains allows you to ask the question "Does this array contain this value?" The following query ask for all the cities that contain "west_coast" in their :regions array.

(go
  (let [->output (->output-fx)
        cities (firemore/get [:cities {:where [":regions" "array-contains" "west_coast"]}])]
    (->output "west coast cities" (async/<! cities))))

:done

"Or" (union) Queries

in (for non-array fields) and array-contains-any (for array fields) both take an array of values to match against a field. For every document in the collection, if any value in the array is found within the field, then the document is returned.

The following query ask for all cities that have a :country field of either "Japan" or "USA".

(go
  (let [->output (->output-fx)
        cities (firemore/get [:cities {:where [":country" "in" ["USA" "Japan"]]}])]
    (->output "west coast cities" (async/<! cities))))

:done

The following query ask for all of the :cities that contain either "west_coast" or "east_coast" in their regions.

(go
  (let [->output (->output-fx)
        cities (firemore/get [:cities {:where [":regions" "array-contains-any" ["west_coast" "east_coast"]]}])]
    (->output "west coast cities" (async/<! cities))))

:done

Order & Limit

Queries also support the orderBy and limit option.

So this in Firestore

citiesRef.where("population", ">", 100000).orderBy("population").orderBy("state", "desc").limit(2)

becomes this in firemore

(go
  (let [->output (->output-fx)
        cities (firemore/get [:cities {:where [[:population "<" 1000000]]
                                       :order [[:population "asc"] [:state "desc"]]
                                       :limit 2}])]
    (->output "two biggest cities" (async/<! cities))))
    
:done

Shorthand

If you have only one :where clause predicate it may be specified as a single vector. The following is equivalent to the above.

[:cities {:where [:population "<" 1000000]
          :order [[:population "asc"] [":state" "desc"]]
          :limit 2}]

The :order values are expanded into 2 element vectors of [<property> "asc"] if they are specified as strings or keywords. So the following is also equivalent to the above.

[:cities {:where [:population "<" 1000000]
          :order [:population ["state" "desc"]]
          :limit 2}]

Authentication

Firestore supports a few primitive and one "ready made" solution for authentication. The primitives include the (logout!), (login-anonymously!), and (user-atom) functions. The "ready made" solution is the use of firebaseUI-Auth as a drop in authentication solution.

Primitives

Usage:
(logout!)

Log out any currently logged in user.
Usage:
(login-anonymously!)

Log in a new anonymous user; noop if already logged in.

Anonymous does NOT mean unidentified (you have a unique user id in :uid). Anonymous does mean that we don't know your :email, :name, or :photo. Anonymous means that if you logout from this account or loose access to this system, there would be no way to log back in as this anonymous user (though you could always login as a new anonymous user).

FirebaseUI-Auth

FirebaseUI-Auth FirebaseUI-Auth provides a drop-in authentication solution that handles the UI flows for signing in users with email/passwords, phone numbers, and Identity Provider's including Google, Facebook, GitHub, Twitter, Apple, Microsoft, Yahoo, OpenID Connect (OIDC) providers and SAML providers.

For illustrating details please consult the docs; but minimal setup is to add the following two lines to your index.html within the <head> tag.

<script src="https://www.gstatic.com/firebasejs/ui/4.5.1/firebase-ui-auth.js"></script>
<link type="text/css" rel="stylesheet" href="https://www.gstatic.com/firebasejs/ui/4.5.1/firebase-ui-auth.css" />

Then enable and instantiate FirebaseUI-Auth by adding the following to the body tag in your index.html.

<script>
   FIREBASE_AUTH_UI_CONFIG = {enabled: true}
</script>
<div id="firebaseui-auth-container"></div>

Note that this must be added BEFORE your clojurescript (using firemore) application is loaded; so your body might look something like the following.

<body>
  <script>
    FIREBASE_AUTH_UI_CONFIG = {enabled: true}
  </script>
  <div id="firebaseui-auth-container"></div>
  <script src="js/app.js"></script>
</body>

Note that there are more options that can be configured for FirebaseUI-Auth; here is the map containing all the config options.

{  ;; Do you want to enable the FirebaseUI-Auth?
   :enabled false

   ;; URL that you will redirect to upon signin success
   :signInSuccessUrl "/"

   ;; The options you want enabled as part of this firebase app
   :signInOptions [
                   "anonymous"    ;; Continue as guest
                   "facebook.com" ;; Sign in with Facebook
                   "github.com"   ;; Sign in with GitHub
                   "google.com"   ;; Sign in with Google
                   "twitter.com"  ;; Sign in with Twitter
                   "password"     ;; Sign in with email
                   "phone"        ;; Sign in with phone
                   ]

   ;; Your Terms of Service
   :tosUrl "<your-tos-url>"

   ;; Redirect to your privacy policy
   :privacyPolicyUrl #(js/window.location.assign "<your-privacy-policy-url>")

   ;; querySelector of the DOM element that firebaseUI-auth should be rendered within
   :container-selector "#firebaseui-auth-container"}

Get Current User

Regardless of how you log in, you still need to know about the currently logged in user. (user-atom) will return an atom that will be updated to reflect the currently logged in user. More information about the keys and values within this atom can be found here.

Usage
(user-atom)

Return the atom that reflects the state of currently logged in user

Contributing

Pull Request are always welcome and appreciated. If you want to discuss firemore, I am available most readily:

  1. On clojurians.slack.com under #firemore.
  2. Through the issue tracking system.
  3. By email at stephen@read-line.com .

Credits

Stephen Cagle is a Senior Software Engineer at Dividend Finance in San Francisco, CA. He is the original (currently only, but always accepting PRs!) creator/maintainer of firemore. @github @linkedin

Man (Stephen Cagle) holding beer & small dog (Chihuahua)

License

MIT License

Copyright (c) 2019 Stephen Cagle