diff --git a/.gitignore b/.gitignore index d4a3ab9..5a2d640 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ checkouts/ pom.xml .firebaserc + +# MacOS system file +.DS_Store diff --git a/README.md b/README.md index 38e9891..9c4a960 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ See [Firebase docs][phone-auth] for details. The firebase database is a tree. You can write values to nodes in a tree, or push them to auto-generated unique sub-nodes of a node. In re-frame-firebase, these are exposed -through the `:firebase/write` `:firebase/push` and `:firebase/update` effect handlers. +through the `:firebase/write` and `:firebase/push` effect handlers. Each takes parameters: - `:path` - A vector representing a node in the firebase tree, e.g. `[:my :node]` @@ -269,7 +269,10 @@ Each takes parameters: - `:on-success` - Event vector or function to call when write succeeds. - `:on-failure` - Event vector or function to call with the error. -Example: +There are also the atomic `:firebase/update`, `:firebase/transaction`, and +`:firebase/swap` effect handlers, discussed below. + +Write example: ```clojure (re-frame/reg-event-fx @@ -318,8 +321,57 @@ Example (diff in bold): [multi-location-update-blogpost]: https://firebase.googleblog.com/2015/09/introducing-multi-location-updates-and_86.html -Re-frame-firebase also supplies `:firebase/multi` to allow multiple write and/or -pushes from a single event: +The `:firebase/transaction` effect handler and its more Clojure-y variant +`:firebase/swap` perform atomic modifications to the tree. + +In `:firebase/transaction`, the `:transaction-update` parameter is a function that takes +one parameter that is the old value at the `:path` location and returns the new +value. Note that the function may be called multiple times, so should be free of side +effects. + +The function must also tolerate a `nil` input gracefully. To abort a transaction, say to +avoid overwriting an existing value, the function returns `js/undefined`. + +Finally, note that the `:apply-locally` boolean indicates whether the local +firebase-system cached value should be applied optimistically, which may result in more +than one update event to be emitted if the function needs to be run more than once. The +default value is `true`. + +```clojure +{:firebase/transaction {:path [:my :data] + :transaction-update (fn [old-val] (if old-val (inc old-val))) + :apply-locally false ;; default is true = multiple update events may be received if transaction-update needs to be run more than once. + ;; The on-* handlers can also take a re-frame event + :on-success (fn [snapshot committed] (if committed (prn "Transaction committed: " snapshot))) + :on-failure (fn [err snapshot committed] (prn "Error: " err))}} +``` + +`:firebase/swap` is similar to `:firebase/transaction` but takes an `:argv` argument, +typically a vector. The argument `:f` is the renamed `:transaction-update`, the +update function. The old value at the `:path` is prepended to `:argv` and then `:f` +is applied much like `clojure.core\swap!` does for atoms. + +Both atomic effect handlers are provided to appeal to users coming from Firebase-first +or Clojure-first backgrounds, respectively. For those coming from Firebase, note that +the `snapshot` and `committed` parameters are reversed in on-*. This is to facilitate +re-frame event handlers as they receive only the first passed parameter, ignoring the +rest. Passing snapshot rather than committed makes for more useful possibilities. + +Example (diff in bold): + +
+{:firebase/swap {:path [:my :data]
+                  :f + 
+                  :argv [2 3] ;; So the swap will perform (+ old-value 2 3) 
+                  :apply-locally false  ;; default is true = multiple update events may be received if transaction-update needs to be run more than once.
+                  ;; The on-* handlers can also take a re-frame event 
+                  :on-success (fn [snapshot committed] (if committed (prn "Transaction committed: " snapshot)))
+                  :on-failure [:handle-failure]}}
+
+ + +Re-frame-firebase also supplies `:firebase/multi` to allow multiple writes and other +effects from a single event: ```clojure (re-frame/reg-event-fx diff --git a/src/com/degel/re_frame_firebase.cljs b/src/com/degel/re_frame_firebase.cljs index e71dbc8..aa2b3c7 100644 --- a/src/com/degel/re_frame_firebase.cljs +++ b/src/com/degel/re_frame_firebase.cljs @@ -35,6 +35,28 @@ ;;; (re-frame/reg-fx :firebase/update database/update-effect) +;;; Transactionally reads and writes a value to Firebase. NB: :transaction-update function +;;; may run more than once so must be free of side effects. Importantly, it must be able +;;; to handle null data. To abort a transaction, return js/undefined. +;;; See https://firebase.google.com/docs/reference/js/firebase.database.Reference#transaction +;;; +;;; Examples FX: +;;; {:firebase/transaction {:path [:my :data] +;;; :transaction-update (fn [old-val] (if old-val (inc old-val))) +;;; :apply-locally false ;; default is true = multiple update events may be received if transaction-update needs to be run more than once. +;;; ;; The on-* handlers can also take a re-frame event +;;; :on-success (fn [snapshot committed] (if committed (prn "Transaction committed: " snapshot))) +;;; :on-failure (fn [err snapshot committed] (prn "Error: " err))}} +;;; +;;; {:firebase/swap {:path [:my :data] +;;; :f + +;;; :argv [2 3] +;;; :apply-locally false ;; default is true = multiple update events may be received if transaction-update needs to be run more than once. +;;; ;; The on-* handlers can also take a re-frame event +;;; :on-success (fn [snapshot committed] (if committed (prn "Transaction committed: " snapshot))) +;;; :on-failure [:firebase-error]}} +(re-frame/reg-fx :firebase/transaction database/transaction-effect) +(re-frame/reg-fx :firebase/swap database/swap-effect) ; A synonym with :argv for update function :f ;;; Write a value to a Firebase list. ;;; See https://firebase.google.com/docs/reference/js/firebase.database.Reference#push @@ -74,6 +96,8 @@ :firebase/write (database/write-effect args) :firebase/update (database/update-effect args) :firebase/push (database/push-effect args) + :firebase/transaction (database/transaction-effect args) + :firebase/swap (database/swap-effect args) :firebase/read-once (database/once-effect args) :firestore/delete (firestore/delete-effect args) :firestore/set (firestore/set-effect args) diff --git a/src/com/degel/re_frame_firebase/database.cljs b/src/com/degel/re_frame_firebase/database.cljs index 2208695..137734e 100644 --- a/src/com/degel/re_frame_firebase/database.cljs +++ b/src/com/degel/re_frame_firebase/database.cljs @@ -38,6 +38,44 @@ (def ^:private update-effect updater) +(defn- transaction->js + [retval] + ;; Preserve js/undefined as it signals to abort the transaction. + ;; https://firebase.google.com/docs/reference/js/firebase.database.Reference#transaction + (if (= js/undefined retval) + retval + (clj->js retval))) + +(defn- transaction-update-wrapper [transaction-update] + (fn [old-value] + (-> old-value + js->clj + clojure.walk/keywordize-keys + transaction-update + transaction->js))) + +(defn- transactioner [{:keys [path transaction-update on-success on-failure apply-locally]}] + (.transaction (fb-ref path) + (transaction-update-wrapper transaction-update) + (success-failure-wrapper on-success on-failure) + ;; Force apply-locally to be a boolean, as required by .transaction. + (if (or (false? apply-locally) + (nil? apply-locally)) + false + true))) + +(def transaction-effect transactioner) + +(defn- swapper [{:keys [path f argv on-success on-failure apply-locally]}] + (transactioner + {:path path + :transaction-update (fn [old-val] (apply f old-val argv)) + :on-success on-success + :on-failure on-failure + :apply-locally apply-locally})) + +(def swap-effect swapper) + (defn push-effect [{:keys [path value on-success on-failure] :as all}] (let [key (.-key (.push (fb-ref path)))] (setter (assoc all diff --git a/src/com/degel/re_frame_firebase/helpers.cljs b/src/com/degel/re_frame_firebase/helpers.cljs index de79167..3b7a222 100644 --- a/src/com/degel/re_frame_firebase/helpers.cljs +++ b/src/com/degel/re_frame_firebase/helpers.cljs @@ -29,14 +29,50 @@ (.catch promise (re-utils/event->fn on-failure)) (.catch promise (core/default-error-handler)))) - (defn success-failure-wrapper [on-success on-failure] {:pre [(utils/validate (s/nilable :re-frame/vec-or-fn) on-success) (utils/validate (s/nilable :re-frame/vec-or-fn) on-failure)] :post (fn? %)} (let [on-success (and on-success (re-utils/event->fn on-success)) - on-failure (and on-failure (re-utils/event->fn on-failure))] - (fn [err] - (cond (nil? err) (when on-success (on-success)) - on-failure (on-failure err) - :else ((core/default-error-handler) err))))) + on-failure (and on-failure (re-utils/event->fn on-failure)) + wrapped-handler (fn + ([err] (cond (nil? err) (when on-success (on-success)) + on-failure (on-failure err) + :else ((core/default-error-handler) err))) + + ;; I am unable to find in the Google Firebase documentation* a 2-arity + ;; callback for .set .update or .transaction that uses this wrapper. Yet, I've + ;; observed that such a callback exists specifically on .update. With + ;; trepidation arising from minimal ad hoc testing, I am forwarding the second + ;; parameter, assuming that this behavior was undetected and inconsequential before + ;; I wrote wrapped-handler to be multi-arity. + ;; + ;; [TODO] Find the reason for this 2-arity version and properly dispatch it. + ;; + ;; * https://firebase.google.com/docs/reference/js/firebase.database.Reference + ([err other] + (cond (nil? err) (when on-success (on-success other)) + on-failure (on-failure err other) + :else ((core/default-error-handler) err))) + + ;; onComplete invoked in :firebase/transaction and :firebase/swap accepts an + ;; error code, a boolean indicating committed status, and a snapshot of the + ;; data at that path. + ;; + ;; This is useful for exposing state changes upon completion of the + ;; transaction, as the transaction-update or f functions must be side-effect + ;; free. Notably here, we reverse the order of committed and snapshot in the + ;; cljs versions on-success and on-failure. So, if the on-success handler is + ;; a re-frame event vector (in iron.re-utils/re-utils they only take the first + ;; parameter), it gets the snapshotted data. An on-failure event handler + ;; would get the error code; it has snapshot and committed reversed for + ;; continuity. + ([err committed snapshot] + (cond (nil? err) (when on-success (on-success (js->clj-tree snapshot) committed)) + on-failure (on-failure err (js->clj-tree snapshot) committed) + :else ((core/default-error-handler) err))))] + wrapped-handler)) + + + +