commit 7c2350f136c783dd670342fdb94c02d3bd36a3b4 Author: gered Date: Wed Dec 24 19:19:43 2014 -0500 initial commit using test application code as the base & comments added diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..278bb6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +/target +/classes +/checkouts +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +.settings/ +.project +.classpath +.idea/ +*.iml +*.ipr +*.iws diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8d9476f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Gered King + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9d4346 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# reagent-data-views + +A Clojure library designed to ... well, that part is up to you. + +## Usage + +FIXME + +## License + +Copyright © 2014 Gered King + +Distributed under the the MIT License. See LICENSE for more details. diff --git a/project.clj b/project.clj new file mode 100644 index 0000000..d1e9dc8 --- /dev/null +++ b/project.clj @@ -0,0 +1,15 @@ +(defproject reagent-data-views "0.1.0-SNAPSHOT" + :description "Support for Reagent components that get pushed realtime database updates from the server." + :url "https://github.com/gered/reagent-data-views" + :license {:name "MIT License" + :url "http://opensource.org/licenses/MIT"} + + :dependencies [[org.clojure/clojure "1.6.0"] + [org.clojure/clojurescript "0.0-2371" :scope "provided"] + [org.clojure/core.async "0.1.346.0-17112a-alpha"] + [reagent "0.5.0-alpha" :scope "provided"] + [clj-browserchannel-messaging "0.0.2"] + [views "0.4.9"]] + + :source-paths ["src/clj"] + :resource-paths ["src/cljs"]) diff --git a/src/clj/reagent_data_views/server/core.clj b/src/clj/reagent_data_views/server/core.clj new file mode 100644 index 0000000..c93af1c --- /dev/null +++ b/src/clj/reagent_data_views/server/core.clj @@ -0,0 +1,130 @@ +(ns reagent-data-views.server.core + (:require + [clojure.core.async :refer [put!]] + [clj-browserchannel-messaging.server :as browserchannel] + [views.core :as vc] + [views.persistence.core :refer [subscriptions]] + [views.persistence.memory :refer [new-memory-persistence]] + [views.router :as vr] + [views.subscribed-views :refer [subscribed-views]])) + +(defonce + ^{:doc "The Views configuration. Used with functions like vexec!, etc."} + views-config (atom nil)) + +(defn- send-deltas [client-id topic body] + (browserchannel/send client-id topic body)) + +(defn- no-filters? [templates] + (not (some + (fn [[_ {:keys [filter-fn]}]] + (not (nil? filter-fn))) + templates))) + +(defn init! + "Initializes configuration for the Views system suitable for use with most applications + that are only using a single database. You should call this function once during + application startup after your database connection has been configured. If your + application requires a more complex Views configuration, you can manually configure + it yourself and pass in the configuration map directly as the only argument. + + Either way, this function needs to be called as in both cases it initializes + views.router to enable automatic routing of view subscription/unsubscription + browserchannel messages. + + * db + Database connection. + + * persistence + Optional. An instance of views.persistence.core/IPersistence. If not specified, a + default views.persistence.memory/ViewsMemoryPersistence instance is used which keeps + all view subscriptions and data in memory (not suitable for large applications). + + * send-fn + Optional. A function that takes care of sending view data deltas to clients (e.g. a + function that sends data to a client via BrowserChannel). The function should accept + 3 arguments: client-id, topic and body. If not specified, a default function that + sends deltas to the subscribed BrowserChannel client is used. + + * subscriber-key + Optional. A function applied against incoming BrowserChannel subscription messages + that is used to get the client-id from the message. If not used, the default is + :browserchannel-session-id. + + * templates + Map of views used by this application. Keys are the view names and the values are maps + containing information about each view. The template map values can contain the + following keys: + + :fn + Required. A var of a function (e.g. #'func or (var func)) that returns a HoneySQL + SELECT query map. This query when run should return the data that this view + represents. The function can receive any number of arguments which will be passed in + as-is from the view-sig (everything after the name). + + :post-fn + Optional. A function that can be used to filter the data returned by the view. + Receives one argument (the data). + + :filter-fn + Optional. A function that is run before a view subscription is processed. If false + is returned, the view subscription is denied. This function receives 2 arguments: + the raw BrowserChannel subscription request message and the view-sig of the view + being subscribed to. + + Additionally, the template map you pass in can contain metadata (on the map itself) + that has a :filter-fn key which works the same as individual view filter-fn's + described above, except that this one will be global and run before subscriptions + to any view." + ([config] + (vr/init! config browserchannel/incoming-messages-pub) + (reset! views-config config)) + ([db templates & {:keys [persistence send-fn subscriber-key]}] + (let [persistence (or persistence (new-memory-persistence)) + send-fn (or send-fn send-deltas) + subscriber-key (or subscriber-key :browserchannel-session-id)] + (init! (-> {:db db + :subscriber-key-fn subscriber-key + :templates templates + :persistence persistence + :send-fn send-fn + :unsafe? (no-filters? templates)} + (vc/config) + ; these just keep some useful things around that are handy to have + ; for 'simpler' use-cases. vc/config returns a map without these + ; present (even though we passed them in). + ; this is not necessarily desirable behaviour for certain advanced + ; configurations! + (assoc :persistence persistence) + (assoc :namespace :default-ns) + (assoc :subscriber-key-fn subscriber-key)))))) + +(def + ^{:doc "Middleware for use with clj-browserchannel-messaging that performs important housekeeping operations."} + views-middleware + {:on-close (fn [handler] + (fn [browserchannel-session-id request reason] + ; views.router is notified of session disconnects when messages of this type + ; are received on the channel passed to views.router/init!. we simply + ; inject a disconnect message on this channel when the browserchannel session + ; is closed and all is good + (put! browserchannel/incoming-messages + (-> {:topic :client-channel + :body :disconnect} + (assoc (:subscriber-key @views-config) browserchannel-session-id))) + (handler browserchannel-session-id request reason)))}) + +(defn get-subscribed-views + "Returns information about the views that are currently subscribed to by clients." + [] + (let [bsv (:base-subscribed-views @views-config) + namespace (or (:namespace @views-config) :default-ns)] + (subscribed-views bsv namespace))) + +(defn get-subscriptions + "Returns a set of subscriber-keys representing clients subscribed to the views + identified by the list of view signatures specified." + [view-sigs] + (let [persistence (:persistence @views-config) + namespace (or (:namespace @views-config) :default-ns)] + (subscriptions persistence namespace view-sigs))) \ No newline at end of file diff --git a/src/cljs/reagent_data_views/client/component.clj b/src/cljs/reagent_data_views/client/component.clj new file mode 100644 index 0000000..edcdecc --- /dev/null +++ b/src/cljs/reagent_data_views/client/component.clj @@ -0,0 +1,24 @@ +(ns reagent-data-views.client.component) + +(defmacro def-views-component + [component-name args view-sigs & body] + `(defn ~component-name ~args + (let [gen-view-sigs# (fn ~args ~view-sigs) + view-sigs-atom# (atom nil)] + (reagent.core/create-class + {:component-will-mount + (fn [this#] + (reset! view-sigs-atom# (apply gen-view-sigs# (rest (reagent.core/argv this#)))) + (reagent-data-views.client.core/subscribe! (deref view-sigs-atom#))) + + :component-will-unmount + (fn [this#] + (reagent-data-views.client.core/unsubscribe! (deref view-sigs-atom#))) + + :component-did-update + (fn [this# old-argv#] + (reagent-data-views.client.core/update-view-component-sigs this# gen-view-sigs# view-sigs-atom#)) + + :component-function + (fn ~args + ~@body)})))) diff --git a/src/cljs/reagent_data_views/client/core.cljs b/src/cljs/reagent_data_views/client/core.cljs new file mode 100644 index 0000000..12d58ea --- /dev/null +++ b/src/cljs/reagent_data_views/client/core.cljs @@ -0,0 +1,132 @@ +(ns reagent-data-views.client.core + (:require + [reagent.core :as r] + [clj-browserchannel-messaging.client :as browserchannel])) + +;; IMPORTANT NOTE: +;; We are using Reagent's built-in RCursor instead of the one provided by reagent-cursor +;; because as of reagent 0.5.0 there is added performance improvements to protect +;; against extra rerenders when deref'ing cursors. +;; This is very important for us here as we store all view subscription data in a single +;; Reagent atom and use cursors keyed by view-sig to access the data. With reagent 0.5.0 +;; cursors, Component A deref'ing view-sig X will not rerender when Component B +;; deref'ing view-sig Y receives updated data. + +(defonce view-data (r/atom {})) + +; return items in b that don't exist in a +(defn- diff [a b] + (vec + (reduce + (fn [item-a item-b] + (remove #(= % item-b) item-a)) + a b))) + +(declare update-subscriptions!) + +(defn update-view-component-sigs + "Not intended to be used outside of the def-view-component macro's + internal functionality." + [owner view-sig-gen-fn view-sigs-atom] + (let [new-args (rest (r/argv owner)) + old-sigs @view-sigs-atom + new-sigs (apply view-sig-gen-fn new-args)] + (when (not= old-sigs new-sigs) + (let [sigs-to-sub (diff new-sigs old-sigs) + sigs-to-unsub (diff old-sigs new-sigs)] + (update-subscriptions! sigs-to-sub sigs-to-unsub) + (if (not= old-sigs new-sigs) + (reset! view-sigs-atom new-sigs)))))) + +(defn get-view-sig-cursor + "Returns a Reagent cursor that can be used to access the data for this view. + NOTE: This is intended to be used in a read-only manner. Using this cursor + to change the data will *not* propagate to the server or any other + clients currently subscribed to this view." + [view-sig] + (r/cursor [view-sig] view-data)) + +(defn- get-views-by-name [view-name] + (filter + (fn [[view-sig _]] + (= view-name (first view-sig))) + @view-data)) + +(defn get-view-cursor + "Returns a Reagent cursor that can be used to access the data for the view-sig + with the specified name. If there is currently multiple subscriptions to views + with the same name (but different arguments), this will throw an error. + NOTE: This is intended to be used in a read-only manner. Using this cursor + to change the data will *not* propagate to the server or any other + clients currently subscribed to this view." + [view-name] + (let [view-sig (get-views-by-name view-name)] + (if (> (count view-sig) 1) + (throw (str "More then one view signature by the name \"" view-name "\" found.")) + (get-view-sig-cursor (ffirst view-sig))))) + +(defn- add-initial-view-data! [view-sig data] + (let [cursor (get-view-sig-cursor view-sig)] + (reset! cursor data))) + +(defn- remove-view-data! [view-sig] + (swap! view-data dissoc view-sig)) + +(defn- apply-delete-deltas [existing-data delete-deltas] + (reduce + (fn [result row-to-delete] + (remove #(= % row-to-delete) result)) + existing-data + delete-deltas)) + +(defn- apply-insert-deltas [existing-data insert-deltas] + (concat existing-data insert-deltas)) + +(defn- apply-deltas! [view-sig deltas] + (let [cursor (get-view-sig-cursor view-sig)] + (doseq [{:keys [refresh-set insert-deltas delete-deltas]} deltas] + (if refresh-set (reset! cursor refresh-set)) + (if (seq delete-deltas) (swap! cursor apply-delete-deltas delete-deltas)) + (if (seq insert-deltas) (swap! cursor apply-insert-deltas insert-deltas))))) + +(defn- handle-view-data-init [{:keys [body]}] + (doseq [[view-sig data] body] + (add-initial-view-data! view-sig data))) + +(defn- handle-view-deltas [{:keys [body]}] + (doseq [delta-batch body] + (doseq [[view-sig deltas] delta-batch] + (apply-deltas! view-sig deltas)))) + +(defn unsubscribe! + "Unsubscribes from all of the specified view(s). No further updates from the + server will be received for these views and the latest data received from + the server is cleared." + [view-sigs] + (doseq [view-sig view-sigs] + (remove-view-data! view-sig) + (browserchannel/send :views.unsubscribe [view-sig]))) + +(defn subscribe! + "Subscribes to the specified view(s). Updates to the data on the server will + be automatically pushed out. Use get-data-cursor to read this data and + render it in any component(s)." + [view-sigs] + (doseq [view-sig view-sigs] + (add-initial-view-data! view-sig nil) + (browserchannel/send :views.subscribe [view-sig]))) + +(defn update-subscriptions! + "Unsubscribes from old-view-sigs and then subscribes to new-view-sigs. This + function should be used when one or more arguments to views that are currently + subscribed to have changed." + [new-view-sigs old-view-sigs] + (unsubscribe! old-view-sigs) + (subscribe! new-view-sigs)) + +(defn init! + "Sets up message handling needed to process incoming view subscription deltas. + Should be called once on page load after BrowserChannel has been initialized." + [] + (browserchannel/message-handler :views.init handle-view-data-init) + (browserchannel/message-handler :views.deltas handle-view-deltas))