Repository: forward-blockchain/qlkit Branch: master Commit: be40f9a35a8a Files: 9 Total size: 44.0 KB Directory structure: gitextract_f1839zur/ ├── .gitignore ├── QUERY-SYNTAX.md ├── README.md ├── deps.edn ├── dev/ │ └── user.clj ├── project.clj ├── src/ │ └── qlkit/ │ ├── core.cljc │ └── spec.cljc └── test/ └── qlkit/ └── qlkit_test.clj ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /resources/public/js/compiled/** figwheel_server.log pom.xml *jar /lib/ /classes/ /out/ /target/ /node_modules/ .lein-deps-sum .lein-repl-history .lein-plugins/ .repl .nrepl-port .DS_Store package-lock.json package.json .cpcache .lein-failures ================================================ FILE: QUERY-SYNTAX.md ================================================ # Qlkit Query Syntax A qlkit query is a vector of queries, where each query is a vector of a namespaced `keyword`, an optional parameters `map`, and zero or more child queries. This is a valid qlkit query: ````Clojure [[:fruit/name]] ```` Here's another one: ````Clojure [[:fruit/name {:fruit/count 1}]] ```` Here's a query with children: ````Clojure [[:ex/fruits {} [:fruit/name {}] [:fruit/count {}]]] ```` Child queries can be nested indefinitely: ````Clojure [[:ex/nuts {} [:nut/name {}] [:nut/count {}] [:nut/flavors {} [:flavor/bitter {:min 5}] [:flavor/salty]]]] ```` For convenience a query may contain `nil` queries (that will be elided), or `seq`s (that will be spliced into the query). ### Nil queries are elided These queries are identical: ```` [[:fruit/name] nil] [[:fruit/name]] ```` ### Seqs are spliced into the query The values of a `seq` contained in a query will be spliced in. This allows for calling a function that returns a child query. These queries are identical: ```` [[:ex/fruits [:fruit/name] [:fruit/count]] [[:ex/fruits '([:fruit/name] [:fruit/count])] ```` ### Clojure Spec ````Clojure (require '[clojure.spec.alpha :as s]) (s/def ::query (s/spec (s/and vector? (s/cat :query (s/* ::term))))) (s/def ::term (s/spec (s/and vector? (s/cat :tag keyword? :attrs (s/? map?) :children (s/* ::term))))) ```` ================================================ FILE: README.md ================================================ ![alt text](http://cdn-images-1.medium.com/max/400/1*2f0P9H1JHpr3MNQ3SKEWCQ.png) [Recommended Introductory Article](https://medium.com/p/79b7b118ddac) Qlkit is a ClojureScript web development framework inspired by OmNext. It relies on React for component rendering and makes use of a query language comparable to GraphQL to encapsulate components and to optimize server calls. It is lightweight, with around 300 lines of code in the core qlkit library and no outside dependencies (besides React). Qlkit is designed to be highly composable, and is therefore separated into the following packages: - `qlkit` contains the core routing and parsing engine - [`qlkit-renderer`](http://github.com/forward-blockchain/qlkit-renderer) is an optional "batteries included" component rendering engine - [`qlkit-material-ui`](http://github.com/forward-blockchain/qlkit-material-ui) makes it easy to use [material ui](http://material-ui.com) components in your app Everyone using these libraries is highly encouraged to read their source code: We have done our best to make sure the code in these libraries is easy to understand. ## Installation *Be aware that qlkit is still alpha software at the moment, do not yet rely on it for production development!* To use qlkit, simply put the following dependency in your deps.edn: ``` qlkit {:mvn/version "0.4.0-SNAPSHOT"} ``` The easiest way to use and understand qlkit is to rely on our [fancy todo demo app](http://github.com/forward-blockchain/qlkit-todo-demo) as your starting point. For more advanced ClojureScript users, we have also created a [simpler demo app](http://github.com/forward-blockchain/qlkit-todo-demo-raw) with less "sugar" and which uses default React rendering and sablono. For additional info, [please visit our Wiki](https://github.com/forward-blockchain/qlkit/wiki)! --- _Copyright (c) Conrad Barski. All rights reserved._ _The use and distribution terms for this software are covered by the Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php), the same license used by Clojure._ ================================================ FILE: deps.edn ================================================ {:paths ["src" "resources"] :deps {org.clojure/clojurescript {:mvn/version "1.10.516"} cljsjs/create-react-class {:mvn/version "15.6.3-1"} cljsjs/react {:mvn/version "16.11.0-0"} cljsjs/react-dom {:mvn/version "16.11.0-0"}}} ================================================ FILE: dev/user.clj ================================================ (ns user (:require [figwheel-sidecar.repl-api :as f])) ;; user is a namespace that the Clojure runtime looks for and ;; loads if its available ;; You can place helper functions in here. This is great for starting ;; and stopping your webserver and other development services ;; The definitions in here will be available if you run "lein repl" or launch a ;; Clojure repl some other way ;; You have to ensure that the libraries you :require are listed in your dependencies ;; Once you start down this path ;; you will probably want to look at ;; tools.namespace https://github.com/clojure/tools.namespace ;; and Component https://github.com/stuartsierra/component (defn fig-start "This starts the figwheel server and watch based auto-compiler." [] ;; this call will only work are long as your :cljsbuild and ;; :figwheel configurations are at the top level of your project.clj ;; and are not spread across different lein profiles ;; otherwise you can pass a configuration into start-figwheel! manually (f/start-figwheel!)) (defn fig-stop "Stop the figwheel server and watch based auto-compiler." [] (f/stop-figwheel!)) ;; if you are in an nREPL environment you will need to make sure you ;; have setup piggieback for this to work (defn cljs-repl "Launch a ClojureScript REPL that is connected to your build and host environment." [] (f/cljs-repl)) ================================================ FILE: project.clj ================================================ (defproject qlkit "0.5.0-SNAPSHOT" :description "Clojurescript UI framework inspired by OmNext with Graph Queries" :url "https://github.com/forward-blockchain/qlkit" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :min-lein-version "2.7.1" :dependencies [[org.clojure/clojure "1.10.0"] [org.clojure/clojurescript "1.10.516"] [cljsjs/create-react-class "15.6.3-1"] [cljsjs/react "16.11.0-0"] [cljsjs/react-dom "16.11.0-0"]] :plugins [[lein-figwheel "0.5.15"] [lein-cljsbuild "1.1.7" :exclusions [[org.clojure/clojure]]]] :source-paths ["src"] :cljsbuild {:builds [{:id "dev" :source-paths ["src"] ;; The presence of a :figwheel configuration here ;; will cause figwheel to inject the figwheel client ;; into your build :figwheel {:on-jsload "qlkit.core/on-js-reload" ;; :open-urls will pop open your application ;; in the default browser once Figwheel has ;; started and compiled your application. ;; Comment this out once it no longer serves you. :open-urls ["http://localhost:3449/index.html"]} :compiler {:main qlkit.core :asset-path "js/compiled/out" :output-to "resources/public/js/compiled/qlkit.js" :output-dir "resources/public/js/compiled/out" :source-map-timestamp true ;; To console.log CLJS data-structures make sure you enable devtools in Chrome ;; https://github.com/binaryage/cljs-devtools :install-deps true :npm-deps {:create-react-class "15.6.0"} :preloads [devtools.preload]}} ;; This next build is a compressed minified build for ;; production. You can build this with: ;; lein cljsbuild once min {:id "min" :source-paths ["src"] :compiler {:parallel-build true :main "qlkit.core" :output-to "resources/public/js/compiled/qlkit.js" :language-in :ecmascript6 :language-out :ecmascript3 :optimizations :advanced :pretty-print false :install-deps true :npm-deps {:create-react-class "15.6.0"}}}]} :figwheel {;; :http-server-root "public" ;; default and assumes "resources" ;; :server-port 3449 ;; default ;; :server-ip "127.0.0.1" :css-dirs ["resources/public/css"] ;; watch and update CSS ;; Start an nREPL server into the running figwheel process ;; :nrepl-port 7888 ;; Server Ring Handler (optional) ;; if you want to embed a ring handler into the figwheel http-kit ;; server, this is for simple ring servers, if this ;; doesn't work for you just run your own server :) (see lein-ring) ;; :ring-handler hello_world.server/handler ;; To be able to open files in your editor from the heads up display ;; you will need to put a script on your path. ;; that script will have to take a file path and a line number ;; ie. in ~/bin/myfile-opener ;; #! /bin/sh ;; emacsclient -n +$2 $1 ;; ;; :open-file-command "myfile-opener" ;; if you are using emacsclient you can just use ;; :open-file-command "emacsclient" ;; if you want to disable the REPL ;; :repl false ;; to configure a different figwheel logfile path ;; :server-logfile "tmp/logs/figwheel-logfile.log" ;; to pipe all the output to the repl ;; :server-logfile false } ;; Setting up nREPL for Figwheel and ClojureScript dev ;; Please see: ;; https://github.com/bhauman/lein-figwheel/wiki/Using-the-Figwheel-REPL-within-NRepl :profiles {:dev {:dependencies [[binaryage/devtools "0.9.9"] [figwheel-sidecar "0.5.15"] [com.cemerick/piggieback "0.2.2"]] ;; need to add dev source path here to get user.clj loaded :source-paths ["src" "dev"] ;; for CIDER ;; :plugins [[cider/cider-nrepl "0.12.0"]] :repl-options {:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]} ;; need to add the compliled assets to the :clean-targets :clean-targets ^{:protect false} ["resources/public/js/compiled" :target-path]}}) ================================================ FILE: src/qlkit/core.cljc ================================================ (ns qlkit.core (:require #?@(:cljs [[react-dom :refer [render]] [react :refer [createElement]] [create-react-class :refer [createReactClass]] [goog.object :refer [get]]]) [qlkit.spec :as spec] [clojure.string :as st])) #?(:clj (defmacro defcomponent* [nam & bodies] "This macro lets you declare a component class. It can contain the sections of state, query, render, component-did-mount and/or component-will-unmount. It will define a name, which can be directly referenced in render functions to embed nested qlkit components." (doseq [[nam] bodies] (when-not ('#{state query render component-did-mount component-will-unmount component-will-receive-props} nam) (throw (ex-info (str "Unknown component member " nam) {})))) `(let [key# (keyword ~(str (:name (:ns &env))) ~(name nam))] (def ~nam key#) (#'add-class key# ~(into {:display-name (name nam)} (for [[nam & more :as body] bodies] (if ('#{state query} nam) [(keyword nam) (last more)] [(keyword nam) `(fn ~(first more) ~@(rest more))]))))))) (defn safe-deref [state] (let [k #?(:clj (instance? clojure.lang.IDeref state) :cljs (satisfies? IDeref state))] (if k @state state))) (defn warning [msg] #?(:clj (throw (ex-info msg {})) :cljs (if (not (exists? js/console)) (println msg) ((or js/console.error js/console.warn js/console.log identity) msg)))) (defonce mount-info (atom {})) (defn- actualize [x] "This function makes sure all shallow lazy sequences are expanded, IF there is a lazy sequence." (if (seq? x) (doall x) x)) (defn- mutation-query-term? [query-term] "Indicates if the mutation term is a mutation- Note that this is a check for a 'shallow' mutation, by design subterms could still be a mutation." (= \! (last (name (first query-term))))) (defn get-fn [f & args] (if (instance? #?(:cljs MultiFn :clj clojure.lang.MultiFn) f) (->> (apply (let [v #?(:cljs (.-dispatch-fn f) :clj (.-dispatchFn f))] v) args) (get-method f)) f)) (defn- parse-query-term [query-term env] "Parses a single query term, i.e. something in the form [:person {} [:person/name] [:person/age]]. The environment is used to pass info from parent queries down to child queries." (let [{:keys [state parsers]} @mount-info {:keys [read mutate remote]} parsers mutate-fn (get-fn mutate query-term env state)] (if (or (not (mutation-query-term? query-term)) mutate-fn (get-fn remote query-term state)) (actualize (cond (mutation-query-term? query-term) (when mutate-fn (mutate-fn query-term env state)) read (read query-term env (safe-deref state)) :else nil)) (warning (str "[QlKit] mutate! query must have either a mutate or a remote parser: " (pr-str query-term)))))) (defn parse-query "Parses an entire query, i.e. something with multiple query terms, such as [[:person {} [:person/name]] [:widget {} [:widget/name]]]. The output of 'parse-query' is meant to be sent by the server to the client to pass back query results." ([query env] (doall (for [query-term query] (parse-query-term query-term env)))) ([query] (parse-query query {}))) (defn- parse-query-into-map [query env] "This parses a query so the results are in nested maps for easy access. This is used for all internal query parsing in cases where there are unique keys for query terms, which is true except for the root query returned by 'transact!', which can have duplicate keys in order to guarantee side-effect order." (into #?(:clj {} ;;Only components need local env/query, so let's strip them for server-side requests :cljs {::env env ::query query}) (map vector (map first query) (parse-query query env)))) (defn parse-children [query-term env] "Takes a query and the environment and triggers all children. The key goal here is that environmental values passed to children need to be marked with the parent query they originated from, to aid in generating a well-formed remote query." (parse-query-into-map (drop 2 query-term) (assoc env ::parent-env (assoc env ::query-key (first query-term))))) (def ^{:doc "Atom containing the qlkit classes created by defcomponent"} classes (atom {})) (declare react-class) (defn- splice-in-seqs [coll] "Supports 'seq splicing' and 'nil eliding'... i.e. converts [:foo (list :bar :baz)] into [:foo :bar :baz] and [:foo nil :baz] into [:foo :baz]" (reduce (fn [acc item] (cond (seq? item) (vec (concat acc (splice-in-seqs item))) item (conj acc item) :else acc)) [] coll)) (defn- normalize-query-helper [query] "Splices in seqs recursively, and also puts in missing empty attribute lists." (for [item (splice-in-seqs query)] (let [maybe-params (second item) [nam params children] (if (and maybe-params (and (not (vector? maybe-params)) (not (seq? maybe-params)))) [(first item) (second item) (drop 2 item)] [(first item) {} (rest item)])] (apply vector nam params (normalize-query-helper children))))) (defn- aggregate-params [params-coll] "Aggregates params accross similar query terms with different params. If one of the query terms is the empty query {} it must be the only query (or the params will become 'overspecialized') Otherwise, param key names across terms must be distinct OR identical in value OR they must both have collection values (so that concating them is possible)" (let [[param & more] (distinct params-coll)] (reduce (fn [acc item] (if (and (seq acc) (seq item)) (into {} (for [key (keys (merge acc item))] [key (let [acc-val (acc key) item-val (item key)] (cond (not acc-val) item-val (not item-val) acc-val (= acc-val item-val) acc-val (and (coll? acc-val) (coll? item-val)) (apply conj acc-val item-val) :else (throw (ex-info "query terms with params containing identical keys that have different non-sequence values cannot be merged." {}))))])) (throw (ex-info "query terms with empty and non-empty params cannot be merged." {})))) param more))) (defn- aggregate-read-queries [query] "If two query terms of the same type exist in the query, and they are not separated by a mutation query, we combine them, recursively." (when-let [[query-term & more] (seq query)] (if (mutation-query-term? query-term) (cons query-term (aggregate-read-queries more)) (let [[nam params & children] query-term {:keys [extra-children extra-params remaining]} (reduce (fn [{:keys [finished] :as acc} [cur-nam cur-params & cur-children :as item]] (cond finished (update acc :remaining conj item) (mutation-query-term? item) (-> acc (assoc :finished true) (update :remaining conj item)) (= cur-nam nam) (-> acc (update :extra-children (fn [children] (apply conj children cur-children))) (update :extra-params (fn [params] (conj params cur-params)))) :else (update acc :remaining conj item))) {:extra-children [] :extra-params [params] :remaining [] :finished false} more)] `[[~nam ~(aggregate-params extra-params) ~@(aggregate-read-queries (concat children extra-children))] ~@(aggregate-read-queries remaining)])))) (defn- normalize-query [query] (aggregate-read-queries (normalize-query-helper query))) (defn- add-class [nam class] "Adds a qlkit class to the global list of classes. Note that a 'qlkit class' is just a clojure map." (assert (:render class)) (when-let [query (:query class)] (try (spec/query-spec (vec (normalize-query query)) :reject-mutations) (catch #?(:clj Exception :cljs :default) e #?(:cljs (warning (str "add-class: parse error in " nam))) (throw e)))) (swap! classes assoc nam (cond-> class (:query class) (update :query normalize-query) #?(:cljs true :clj false) (assoc ::react-class (react-class class))))) (defn get-query [key] "Returns the query for a class. Note that in qlkit, queries are not changed at runtime and hence just retrieved at the class-level." (seq (:query (@classes key)))) (defn- parse-query-remote ([query env] "This parses a query and sends off its parts to any 'remote' query handlers. Returns another query (the query to send to the server) as a result." query (normalize-query (reduce (fn [acc item] (let [{:keys [state parsers]} @mount-info {:keys [remote]} parsers state' (safe-deref state)] (if (get-fn remote item env state') (if-let [v (remote item env state')] (conj acc v) acc) acc))) [] query))) ([query] (parse-query-remote query {}))) (defn parse-children-remote [[dispatch-key params & chi :as query] env] "This is a function you can use within a remote query parser to iteratively execute the children of the query." (let [chi-remote (parse-query-remote chi env)] (when (seq chi-remote) (vec (concat [dispatch-key params] chi-remote))))) (defn- parse-query-term-sync [[key :as query-term] result env] "Calls the sync parsers for a query term, which are responsible for merging server results into the client state." (if-let [sync-fun (get-fn (:sync (:parsers @mount-info)) query-term result env (:state @mount-info))] (actualize (sync-fun query-term result env (:state @mount-info))) (or (mutation-query-term? query-term) (warning (str "[QlKit] Missing sync parser but received sync query: " (pr-str query-term)))))) (defn parse-children-sync [query-term result env] "This function can be called from sync parsers to recursively perform child sync queries." (doseq [[key :as child-query-term] (drop 2 query-term)] (parse-query-term-sync child-query-term (result key) env))) (defn- map-delta [map1 map2] "Finds the minimal X such that (merge map1 X) = (merge map1 map2)" (into {} (filter (fn [[k v]] (not= v (map1 k))) map2))) (defn- root-query [env query] "Takes a query that is relative to a component in the hierarchy and converts it into a query at the root level. Note that each term in the original query will be given its own 'root' in the resulting query, which is done to control ordering of side effects." (for [query-term query] (loop [query-term query-term env (::parent-env env)] (if env (let [parent-env (::parent-env env)] (recur [(::query-key env) (dissoc (if parent-env (map-delta parent-env env) env) ::parent-env ::query-key) query-term] parent-env)) query-term)))) (declare refresh) (declare tick) (defn mount [args] "This is used to mount qlkit tied to a dom element (or without a dom element, when used on the server.) The args map can contain :parsers (the map of parsers) :component (The name of the root qlkit component) :state (a state atom) and :remote-handler (function to call for sending out changes to the server). Only one mount can be set up on the client, and one on the server." (assert (map? args) "QlKit needs a Map argument defining the options.") (let [new-version (inc (or (:version @mount-info) 0))] (reset! mount-info (assoc args :version new-version)) (when-not (:server? args) (refresh true)) #?(:cljs (js/window.requestAnimationFrame (partial tick new-version))))) (defn perform-remote-query [query] "This calls the remote handler to process the remote query and offers up a callback that is called when the server has returned the results from the query." (when (seq query) (if-let [handler (:remote-handler @mount-info)] (handler query (fn [results] (doseq [[k v] (map vector query results)] (parse-query-term-sync k v {})) (refresh false))) (warning (str "[QlKit] Missing :remote-handler but received query: " (pr-str query)))))) (defn transact!* [this & query] "This function handles a mutating transaction, originating (usually) from a component context. It first runs the local mutations by parsing the query locally, then sends the remote parts to the server, finally rerenders the entire UI." (let [[env component-query] (if this (let [props (.-props this) env (get props "env") query (get props "query")] [env query]) [{} nil]) query (root-query env (normalize-query (concat query component-query))) {{spec :spec} :parsers} @mount-info] (spec/query-spec (vec query)) (when spec (spec (vec query) :synchronous)) (parse-query query env) (let [q (seq (parse-query-remote query))] (spec/query-spec (vec q)) (when spec (spec (vec q) :asynchronous)) (perform-remote-query q)) (refresh false))) #?(:clj (do (defn- refresh [remote-query?] nil) (defn- tick [mount-version] nil)) :cljs (do (def ^{:doc "Atom containing a function that takes a React component and returns the React component at the root of the application's DOM."} make-root-component (atom identity)) (def ^{:doc "Atom containing a map of HTML element keywords (e.g. :div or :table) to React components."} component-registry (atom {})) (defn register-component [k v] "Associate an HTML element keyword with a React component." (swap! component-registry assoc k v)) (declare create-instance) (defn- clj-state [state] "Pulls state out of the react component state." (if state (.-state state) {})) (defn- clj-atts [props] "Fetches the component atts out of its react props" (if props (get props "atts") {})) (def rendering-middleware (atom [])) (defn- react-class [class] "Creates a react class from the qlkit class description format" (js/createReactClass (let [mount (:component-did-mount class) unmount (:component-will-unmount class) rprops (:component-will-receive-props class) dname (:display-name class) obj #js {:displayName dname :shouldComponentUpdate (fn [next-props next-state] (this-as this (or (not= (clj-atts (.-props this)) (clj-atts next-props)) (not= (clj-state (.-state this)) (clj-state next-state))))) :getInitialState (fn [] #js {:state (or (:state class) {})}) :render (fn [] (this-as this (reduce (fn [acc item] (item this acc)) ((:render class) this (clj-atts (.-props this)) (clj-state (.-state this))) @rendering-middleware)))}] (when mount (set! (.-componentDidMount obj) (fn [] (this-as this (mount this (clj-atts (.-props this))))))) (when unmount (set! (.-componentWillUnmount obj) (fn [] (this-as this (unmount this (clj-state (.-state this))))))) (when rprops (set! (.-componentWillReceiveProps obj) (fn [props] (this-as this (rprops this (clj-atts props)))))) obj))) (defn update-state!* [this fun & args] "Update the component-local state with the given function" (.setState this (fn [state] #js {:state (apply fun (clj-state state) args)}))) (defn create-instance [component atts] (createElement (::react-class (@classes component)) #js {:atts atts :env (::env atts) :query (::query atts)})) (def dirty (atom 0)) (defn tick [mount-version] (when (= (:version @mount-info) mount-version) (let [cur-dirty @dirty] (reset! dirty 0) (when (and (pos? cur-dirty) (not= @mount-info {})) (let [query (get-query (:component @mount-info)) atts (parse-query-into-map query {})] (render (@make-root-component (create-instance (:component @mount-info) atts)) (:dom-element @mount-info)))) (js/window.requestAnimationFrame (partial tick mount-version))))) (defn- refresh [remote-query?] "Force a redraw of the entire UI. This will trigger local parsers to gather data, and optionally will fetch data from server as well." (when remote-query? (let [query (get-query (:component @mount-info)) atts (parse-query-into-map query {}) {{spec :spec} :parsers} @mount-info] (spec/query-spec (vec query)) (when spec (spec (vec query) :synchronous)) (when remote-query? (perform-remote-query (parse-query-remote query))))) (swap! dirty inc)))) ================================================ FILE: src/qlkit/spec.cljc ================================================ (ns qlkit.spec (:require [clojure.spec.alpha :as s])) (def ^:dynamic *defining-component?*) (defn mutation-query? [k] (= \! (last (name k)))) (defn contains-mutation? [query-term] (->> query-term (filter vector?) (map first) (some mutation-query?))) (s/def ::component-definition-rules (fn [query-term] (if *defining-component?* (not (contains-mutation? query-term)) true))) (s/def ::query-term (s/spec (s/and vector? ::component-definition-rules (s/cat :tag keyword? :attrs (s/? map?) :children (s/* ::query-term))))) (s/def ::query (s/spec (s/and vector? (s/cat :query (s/* ::query-term))))) (defn query-spec [query & [defining-component?]] (binding [*defining-component?* defining-component?] (when-not (s/valid? ::query query) (throw (ex-info (str "Invalid query: \n" query "\n" (with-out-str (s/explain ::query query))) {}))))) ================================================ FILE: test/qlkit/qlkit_test.clj ================================================ (ns qlkit.qlkit-test (:refer-clojure :rename {read core-read sync core-sync}) (:require [clojure.test :refer [deftest is]] [qlkit.core :as ql])) (deftest actualize-test [] ;;non-sequences evaluate to themselves (is (= (#'ql/actualize 1) 1)) (let [x (atom 0) coll (for [_ (range 10)] (swap! x inc))] ;;coll is still lazy (is (= @x 0)) (#'ql/actualize coll) ;;coll is fully evaluated (is (= @x 10)))) (defmulti read (fn [a & args] (first a))) (defmulti mutate (fn [a & args] (first a))) (defmulti remote (fn [a & args] (first a))) (defmulti sync (fn [a & args] (first a))) (defn parse-with [fun query-term] (remove-all-methods read) (remove-all-methods mutate) (remove-all-methods remote) (fun) (#'ql/parse-query-term query-term {})) (deftest parse-query-test [] (reset! ql/mount-info {:parsers {:read read :mutate mutate :remote remote} :state (atom {})}) ;;a read parser result is returned (is (= (parse-with (fn [] (defmethod read :foo [query-term env state] 42)) [:foo]) 42)) ;;a mutate function returns a result, but also performs mutations (let [x (atom 0)] (parse-with (fn [] (defmethod mutate :bar! [query-term env state] (swap! x inc))) [:bar!]) (is (= @x 1))) ;;If no parser is provided, an error is thrown (is (thrown-with-msg? java.lang.IllegalArgumentException #"No method in multimethod" (parse-with (fn []) [:foo]))) ;;A parser can call parse children for recursive parsing (is (= (map #(dissoc % ::ql/env ::ql/query) (parse-with (fn [] (defmethod read :animals [query-term env state] (for [animal-id (range 3)] (ql/parse-children query-term (assoc env :animal-id animal-id)))) (defmethod read :name [query-term env state] ({0 :duck 1 :cat 2 :dog} (:animal-id env)))) [:animals {} [:name]])) [{:name :duck} {:name :cat} {:name :dog}]))) (deftest splice-in-seqs-test [] (is (= (#'ql/splice-in-seqs [:foo (list :bar :baz)]) [:foo :bar :baz]) (= (#'ql/splice-in-seqs [:foo nil :baz]) [:foo :baz]))) (deftest normalize-query-test [] (is (= (#'ql/normalize-query [[:foo [:bar]] [:baz]]) [[:foo {} [:bar {}]] [:baz {}]])) (is (= (#'ql/normalize-query [[:foo {} (list [:bar {} [:qux nil (list [:a] [:b])]])] [:baz]]) [[:foo {} [:bar {} [:qux {} [:a {}] [:b {}]]]] [:baz {}]]))) (deftest add-class-test [] ;;all a qlkit class needs is a render function (let [fun (fn [])] (reset! ql/classes {}) (#'ql/add-class :foo {:render fun}) (is (= @ql/classes {:foo {:render fun}}))) ;;render function missing :( (let [fun (fn [])] (reset! ql/classes {}) (is (thrown-with-msg? java.lang.AssertionError #"Assert failed: \(:render class\)" (#'ql/add-class :foo {:bar fun})))) ;;a query can uptionally be added to the class (let [fun (fn [])] (reset! ql/classes {}) (#'ql/add-class :foo {:render fun :query [[:foo]]}) (is (= @ql/classes {:foo {:render fun :query [[:foo {}]]}}))) ;;query has to match clojure.spec declaration (let [fun (fn [])] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid query" (reset! ql/classes {}) (#'ql/add-class :foo {:render fun :query [["foo"]]}))))) (deftest get-query-test [] (let [fun (fn [])] (reset! ql/classes {}) (#'ql/add-class :foo {:render fun :query [[:foo]]}) (is (= (ql/get-query :foo) [[:foo {}]])))) (deftest mutation-query-test [] (is (= (#'ql/mutation-query-term? [:foo]) false) (= (#'ql/mutation-query-term? [:foo!]) true))) (defn parse-remote-with [fun query] (remove-all-methods remote) (fun) (#'ql/parse-query-remote query)) (deftest parse-query-remote-test [] (reset! ql/mount-info {:parsers {:remote remote} :state (atom nil)}) ;;If the remote returns the query, then we get our query back (is (= (parse-remote-with (fn [] (defmethod remote :foo [query state env] query)) [[:foo]]) [[:foo {}]])) ;;If there are no remotes, we just get an empty seq (is (= (parse-remote-with (fn []) [[:foo]]) nil)) ;;We can parse child queries when parsing a remote query, and parsing functions can modify the query (is (= (parse-remote-with (fn [] (defmethod remote :foo [query state env] (ql/parse-children-remote query env)) (defmethod remote :bar [query state env] [:bar {:baz 42}])) [[:foo {} [:bar]]]) [[:foo {} [:bar {:baz 42}]]]))) (defn parse-sync-with [fun query-term result] (remove-all-methods sync) (fun) (#'ql/parse-query-term-sync query-term result {})) (deftest parse-query-term-sync-test [] (let [state (atom nil)] (reset! ql/mount-info {:parsers {:sync sync} :state state}) ;;The sync merges the result into the state (parse-sync-with (fn [] (defmethod sync :foo [query-term result env state-atom] (reset! state-atom result))) [:foo {}] 42) (is (= @state 42)) ;;If a read query is missing a sync, an error is thrown (is (thrown-with-msg? clojure.lang.ExceptionInfo #"\[QlKit\] Missing sync parser.*" (parse-sync-with (fn []) [:foo {}] 42))) ;;Remote mutations are permitted without a sync parser (parse-sync-with (fn [] ;; a default method is required (defmethod sync :default [a b c d])) [:foo! {}] 42) ;;Here we are calling child sync functions recursively. Note that lasy seqs will be immediately be made un-lazy by qlkit. (reset! state {}) (parse-sync-with (fn [] (defmethod sync :foo [query-term result env state-atom] (map-indexed (fn [index item] (ql/parse-children-sync query-term item (assoc env :id index))) result)) (defmethod sync :bar [query-term result env state-atom] (swap! state-atom assoc (:id env) result))) [:foo {} [:bar]] [{:bar :red} {:bar :green} {:bar :blue}]) (is (= @state {0 :red 1 :green 2 :blue})))) (deftest map-delta-test [] (let [check (fn [map1 map2] (is (= (merge map1 (#'ql/map-delta map1 map2)) (merge map1 map2))))] (check {:a 1} {:b 2}) (check {:a 1} {:a 2 :b 2}) (check {:a 1 :b 2} {:a 2 :b 2}) (is (= (#'ql/map-delta {:a 1} {:a 1}) {})))) (deftest root-query-test [] ;;If we're at the root level and the environment is empty, just evaluates to itself (is (= (#'ql/root-query {} [[:foo]]) [[:foo]])) ;;Here, the :bar parser set an env variable of id=55, need to add that to the root query (is (= (#'ql/root-query {::ql/parent-env {::ql/query-key :bar :id 55}} [[:foo]]) [[:bar {:id 55} [:foo]]])) ;;If the environment has nested parent environments, all env variables end up in the query, but with duplication removed (is (= (#'ql/root-query {::ql/parent-env {::ql/query-key :bar ::ql/parent-env {::ql/query-key :baz :id-b 66 :id-a 77} :id-a 55 :id-b 66}} [[:foo]]) [[:baz {:id-b 66, :id-a 77} [:bar {:id-a 55} [:foo]]]]))) (deftest mount-test [] (ql/mount {:state (atom 5)}) (is (= @(:state @ql/mount-info) 5))) (deftest perform-remote-query-test [] (let [state (atom {:foos {}})] (ql/mount {:remote-handler (fn [query callback] (callback [{:bar 3 :baz 42}])) :parsers {:sync sync} :state state}) (remove-all-methods sync) (defmethod sync :foo [query-term result env state-atom] (ql/parse-children-sync query-term result (assoc env :foo-id 7))) (defmethod sync :bar [query-term result env state-atom] (swap! state-atom assoc-in [:foos (:foo-id env) :bar] result)) (defmethod sync :baz [query-term result env state-atom] (swap! state-atom assoc-in [:foos (:foo-id env) :baz] result)) (ql/perform-remote-query [[:foo {} [:bar] [:baz]]]) (is (= @state {:foos {7 {:bar 3, :baz 42}}})))) (deftest transact!-test [] (let [state (atom {})] (remove-all-methods sync) (remove-all-methods read) (remove-all-methods mutate) (remove-all-methods remote) (defmethod read :foo [query-term env state] 42) (defmethod remote :foo [query-term state env] query-term) (defmethod sync :foo [query-term result env state-atom] (swap! state-atom assoc :foo result)) (ql/mount {:remote-handler (fn [query callback] (callback [:yup])) :parsers {:sync sync :read read :mutate mutate :remote remote} :state state}) (ql/transact!* nil [:foo]) (is (= @state {:foo :yup})))) (deftest aggregate-read-queries-test [] (is (= (#'ql/aggregate-read-queries [[:foo {}]]) [[:foo {}]])) (is (= (#'ql/aggregate-read-queries [[:foo {} [:bar {}]] [:foo {} [:bar {}]]]) [[:foo {} [:bar {}]]])) (is (= (#'ql/aggregate-read-queries [[:foo {} [:bar {}]] [:foo {} [:baz {}]]]) [[:foo {} [:bar {}] [:baz {}]]])) (is (= (#'ql/aggregate-read-queries [[:foo {} [:bar {} [:derp1 {}]]] [:foo {} [:bar {} [:derp2 {}]]]]) [[:foo {} [:bar {} [:derp1 {}] [:derp2 {}]]]])) (is (thrown-with-msg? clojure.lang.ExceptionInfo #"query terms with empty and non-empty params cannot be merged." (#'ql/aggregate-read-queries [[:foo {} [:bar {}]] [:foo {:a 1} [:bar {}]]]))) (is (= (#'ql/aggregate-read-queries [[:foo {:b 2} [:bar {}]] [:foo {:a 1} [:bar {}]]]) [[:foo {:b 2, :a 1} [:bar {}]]])) (is (= (#'ql/aggregate-read-queries [[:foo {}] [:baz {}] [:baz {}] [:bar! {}] [:foo {}]]) [[:foo {}] [:baz {}] [:bar! {}] [:foo {}]])) (is (thrown-with-msg? clojure.lang.ExceptionInfo #"query terms with params containing identical keys that have different non-sequence values cannot be merged." (#'ql/aggregate-read-queries [[:foo {:a 2} [:bar {}]] [:foo {:a 1} [:bar {}]]]))) (is (= (#'ql/aggregate-read-queries [[:foo {:a #{2}} [:bar {}]] [:foo {:a #{1}} [:bar {}]]]) [[:foo {:a #{1 2}} [:bar {}]]])))