diff --git a/.gitignore b/.gitignore index e26f25f..80c7b0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*~ .cpcache .nrepl-port target diff --git a/deps.edn b/deps.edn index 9965efc..9dd9c0c 100644 --- a/deps.edn +++ b/deps.edn @@ -1,4 +1,4 @@ -{:paths ["src/clj" "resources"] +{:paths ["src/clj" "src/cljc" "resources"] :deps {org.clojure/clojure {:mvn/version "1.12.0"} @@ -7,6 +7,9 @@ com.xtdb/xtdb-api {:mvn/version "2.0.0-beta3"} com.xtdb/xtdb-core {:mvn/version "2.0.0-beta3"} + org.postgresql/postgresql {:mvn/version "42.7.4"} + com.github.seancorfield/next.jdbc {:mvn/version "1.3.939"} + ;; Lambda com.amazonaws/aws-lambda-java-core {:mvn/version "1.2.3"} diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 5b63f9d..db4ef91 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -11,10 +11,14 @@ :nrepl {:middleware [cider.nrepl/cider-middleware refactor-nrepl.middleware/wrap-refactor]} - :dev-http {8020 "resources/public"} + :dev-http {8020 "resources/public" + 8021 "out/test"} :builds - {:app + {:test + {:target :browser-test + :test-dir "out/test"} + :app {:target :browser :modules {:app {:entries [xt-play.app]}} diff --git a/src/clj/xt_play/config.clj b/src/clj/xt_play/config.clj new file mode 100644 index 0000000..ef40dc7 --- /dev/null +++ b/src/clj/xt_play/config.clj @@ -0,0 +1,9 @@ +(ns xt-play.config) + +(def db + {:dbtype "postgresql" + :dbname "xtdb" + :user "xtdb" + :password "xtdb" + :host "localhost" + :port 5432}) diff --git a/src/clj/xt_play/handler.clj b/src/clj/xt_play/handler.clj index 0ffad5e..a3b634a 100644 --- a/src/clj/xt_play/handler.clj +++ b/src/clj/xt_play/handler.clj @@ -1,9 +1,7 @@ (ns xt-play.handler - (:require [integrant.core :as ig] - [clojure.edn :as edn] - [clojure.instant :refer [read-instant-date]] - [clojure.spec.alpha :as s] + (:require [clojure.spec.alpha :as s] [clojure.tools.logging :as log] + [integrant.core :as ig] [muuntaja.core :as m] [reitit.coercion.spec :as rcs] [reitit.dev.pretty :as pretty] @@ -11,18 +9,40 @@ [reitit.ring.coercion :as rrc] [reitit.ring.middleware.exception :as exception] [reitit.ring.middleware.muuntaja :as muuntaja] + [ring.middleware.cors :refer [wrap-cors]] [ring.middleware.params :as params] [ring.util.response :as response] - [ring.middleware.cors :refer [wrap-cors]] - [xtdb.api :as xt] - [xtdb.node :as xtn] - [hiccup.page :as h])) + [xt-play.transactions :as txs] + [xt-play.view :as view])) + +;; TODO: +;; [x] Send tx data back asis - data manipulation server side +;; [x] Beta is an option in the type +;; [x] Handle multi tx on pgwire +;; [x] Handle system time on pgwire +;; [x] Manipulate response data server side +;; [x] remove btn +;; [x] banner +;; [x] logging +;; [x] handle todos +;; [x] Tests! +;; [x] Refactor - split into more meaningful files +;; [x] - add config, request, response, xt ns +;; [x] - split out ui to components +;; [x] - Better management on subs +;; [x] cljs tests +;; [] Handle queries in tx? +;; [] Display errors in result box +;; [] automated test runners / pipelines +;; [] extract common tailwind classes, e.g. icon sizes, to standardize (s/def ::system-time (s/nilable string?)) (s/def ::txs string?) (s/def ::tx-batches (s/coll-of (s/keys :req-un [::system-time ::txs]))) (s/def ::query string?) +(s/def ::tx-type #{"sql-beta" "xtql" "sql"}) (s/def ::db-run (s/keys :req-un [::tx-batches ::query])) +(s/def ::beta-db-run (s/keys :req-un [::tx-batches ::query ::tx-type])) (defn- handle-client-error [ex _] {:status 400 @@ -45,85 +65,40 @@ clojure.lang.ExceptionInfo handle-client-error ::exception/default handle-other-error}))) -(def xt-version - (-> (slurp "deps.edn") - (edn/read-string) - (get-in [:deps 'com.xtdb/xtdb-core :mvn/version]))) -(assert (string? xt-version) "xt-version not present") - -(def index - (h/html5 - [:head - [:meta {:charset "utf-8"}] - [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] - [:meta {:name "description" :content ""}] - [:link {:rel "stylesheet" :href "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css"}] - [:link {:rel "stylesheet" :type "text/css" :href "/public/css/main.css"}] - [:script {:src "https://cdn.tailwindcss.com"}] - [:script {:async true - :defer true - :data-website-id "aabeabcb-ad76-47a4-9b4b-bef3fdc39af4" - :src "https://bunseki.juxt.pro/umami.js"}] - [:title "XTDB Play"]] - [:body - [:div {:id "app"}] - [:script {:type "text/javascript" :src "/public/js/compiled/app.js"}] - [:script {:type "text/javascript"} - (str "var xt_version = '" xt-version "';")] - [:script {:type "text/javascript"} - "xt_play.app.init()"]])) - - -(comment - (with-open [node (xtn/start-node {})] - (doseq [st [#inst "2022" #inst "2021"]] - (let [tx (xt/submit-tx node [] {:system-time st}) - results (xt/q node '(from :xt/txs [{:xt/id $tx-id} xt/error]) - {:basis {:at-tx tx} - :args {:tx-id (:tx-id tx)}})] - (when-let [error (-> results first :xt/error)] - (throw (ex-info "Transaction error" {:error error}))))))) - -(defn run-handler [request] +(defn run-handler [{{body :body} :parameters :as request}] (log/debug "run-handler" request) - (let [{:keys [tx-batches query]} (get-in request [:parameters :body]) - ;; TODO: Filter for only the readers required? - read-edn (partial edn/read-string {:readers *data-readers*}) - tx-batches (->> tx-batches - (map #(update % :system-time (fn [s] (when s (read-instant-date s))))) - (map #(update % :txs read-edn))) - query (read-edn query)] - (log/info :db-run {:tx-batches tx-batches :query query}) - (try - (with-open [node (xtn/start-node {})] - ;; Run transactions - (doseq [{:keys [system-time txs]} tx-batches] - (let [tx (xt/submit-tx node txs {:system-time system-time}) - results (xt/q node '(from :xt/txs [{:xt/id $tx-id} xt/error]) - {:args {:tx-id (:tx-id tx)}})] - ;; If any transaction fails, throw the error - (when-let [error (-> results first :xt/error)] - (throw error)))) - ;; Run query - (let [res (xt/q node query (when (string? query) - {:key-fn :snake-case-string}))] - {:status 200 - :body res})) - (catch Exception e - (log/warn :submit-error {:e e}) - (throw e))))) + (log/info :db-run body) + (if-let [result (txs/run!! body)] + {:status 200 + :body result} + {:status 400})) + +(defn docs-run-handler [{{body :body} :parameters :as request}] + (log/debug "docs-run-handler" request) + (log/info :docs-db-run body) + (if-let [result (txs/docs-run!! body)] + {:status 200 + :body result} + {:status 400})) (def routes (ring/router [["/" {:get {:summary "Fetch main page" :handler (fn [_request] - (-> (response/response index) + (-> (response/response view/index) (response/content-type "text/html")))}}] - ["/db-run" + ["/db-run" ;; if the contract for this changes, it'll break the docs, so + ;; either docs need to change, or needs to remain backward + ;; compatible {:post {:summary "Run transactions + a query" :parameters {:body ::db-run} + :handler #'docs-run-handler}}] + + ["/beta-db-run" + {:post {:summary "Run transactions + a query" + :parameters {:body ::beta-db-run} :handler #'run-handler}}] ["/public/*" (ring/create-resource-handler)]] diff --git a/src/clj/xt_play/transactions.clj b/src/clj/xt_play/transactions.clj new file mode 100644 index 0000000..8f05b98 --- /dev/null +++ b/src/clj/xt_play/transactions.clj @@ -0,0 +1,117 @@ +(ns xt-play.transactions + (:require [clojure.string :as str] + [clojure.data.json :as json] + [clojure.instant :refer [read-instant-date]] + [clojure.tools.logging :as log] + [next.jdbc :as jdbc] + [next.jdbc.result-set :as jdbc-res] + [xt-play.config :as config] + [xt-play.util :as util] + [xtdb.api :as xt] + [xtdb.node :as xtn])) + +(defn- encode-txs [tx-type txs] + (case (keyword tx-type) + :sql (->> (str/split txs #";") + (remove str/blank?) + (map #(do [:sql %])) + (vec)) + :xtql (util/read-edn (str "[" txs "]")) + ;;else + txs)) + +(defn- prepare-statements + "Takes a batch of transactions and prepares the jdbc execution args to + be run sequentially" + [tx-batches] + (for [{:keys [txs system-time]} tx-batches] + (remove nil? + [(when system-time + [(format "BEGIN AT SYSTEM_TIME TIMESTAMP '%s'" system-time)]) + [txs] + (when system-time + ["COMMIT"])]))) + +(defn format-system-time [s] + (when s (read-instant-date s))) + +(defn- run!-tx [node tx-type tx-batches query] + (let [tx-batches (->> tx-batches + (map #(update % :system-time format-system-time)) + (map #(update % :txs (partial encode-txs tx-type))))] + (doseq [{:keys [system-time txs] :as batch} tx-batches] + (log/info tx-type "running batch: " batch) + (let [tx (xt/submit-tx node txs {:system-time system-time}) + results (xt/q node '(from :xt/txs [{:xt/id $tx-id} xt/error]) + {:args {:tx-id (:tx-id tx)}})] + ;; If any transaction fails, throw the error + (log/info tx-type "batch complete:" tx ", results:" results) + (when-let [error (-> results first :xt/error)] + (throw error))))) + (log/info tx-type "running query:" query) + (let [res (xt/q node query (when (string? query) + {:key-fn :snake-case-string}))] + (log/info tx-type "XTDB query response:" res) + res)) + +(defn- PGobject->clj [v] + (if (= org.postgresql.util.PGobject (type v)) + (json/read-str (.getValue v) :key-fn keyword) + v)) + +(defn- parse-result [result] + ;; TODO - this shouldn't be needed, a fix is on the way in + ;; a later version of xtdb-jdb + ;; This will only pick up top level objects + (mapv + (fn [row] + (mapv PGobject->clj row)) + result)) + +(defn- run!-with-jdbc-conn [tx-batches query] + (with-open [conn (jdbc/get-connection config/db)] + (doseq [tx (prepare-statements tx-batches) + statement tx] + (log/info "beta executing statement:" statement) + (jdbc/execute! conn statement)) + (log/info "beta running query:" query) + (let [res (jdbc/execute! conn [query] {:builder-fn jdbc-res/as-arrays})] + (log/info "beta query resoponse" res) + (parse-result res)))) + +(defn run!! + "Given transaction batches, a query and the type of transaction to + use, will run transaction batches and queries sequentially, + returning the last query response in column format." + [{:keys [tx-batches query tx-type]}] + (let [query (if (= "xtql" tx-type) (util/read-edn query) query)] + (try + (with-open [node (xtn/start-node {})] + (if (= "sql-beta" tx-type) + (run!-with-jdbc-conn tx-batches query) + (util/map-results->rows + (run!-tx node tx-type tx-batches query)))) + (catch Exception e + (log/warn :submit-error {:e e}) + (throw e))))) + +(defn docs-run!! + "Given transaction batches and a query from the docs, will return the query + response in map format. Assumes tx type is sql." + [{:keys [tx-batches query]}] + (try + (with-open [node (xtn/start-node {})] + (run!-tx node "sql" tx-batches query)) + (catch Exception e + (log/warn :submit-error {:e e}) + (throw e)))) + +(comment + (with-open [node (xtn/start-node {})] + (doseq [st [#inst "2022" #inst "2021"]] + (let [tx (xt/submit-tx node [] {:system-time st}) + results (xt/q node '(from :xt/txs [{:xt/id $tx-id} xt/error]) + {:basis {:at-tx tx} + :args {:tx-id (:tx-id tx)}})] + (when-let [error (-> results first :xt/error)] + (throw (ex-info "Transaction error" {:error error}))))))) diff --git a/src/clj/xt_play/view.clj b/src/clj/xt_play/view.clj new file mode 100644 index 0000000..ae3023a --- /dev/null +++ b/src/clj/xt_play/view.clj @@ -0,0 +1,31 @@ +(ns xt-play.view + (:require [hiccup.page :as h] + [xt-play.util :as util])) + +(def index + (h/html5 + [:head + [:meta {:charset "utf-8"}] + [:meta {:name "viewport" + :content "width=device-width, initial-scale=1"}] + [:meta {:name "description" + :content ""}] + [:link {:rel "stylesheet" + :href "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css"}] + [:link {:rel "stylesheet" + :type "text/css" + :href "/public/css/main.css"}] + [:script {:src "https://cdn.tailwindcss.com"}] + [:script {:async true + :defer true + :data-website-id "aabeabcb-ad76-47a4-9b4b-bef3fdc39af4" + :src "https://bunseki.juxt.pro/umami.js"}] + [:title "XTDB Play"]] + [:body + [:div {:id "app"}] + [:script {:type "text/javascript" + :src "/public/js/compiled/app.js"}] + [:script {:type "text/javascript"} + (str "var xt_version = '" util/xt-version "';")] + [:script {:type "text/javascript"} + "xt_play.app.init()"]])) diff --git a/src/cljc/xt_play/util.cljc b/src/cljc/xt_play/util.cljc new file mode 100644 index 0000000..a6cb0b7 --- /dev/null +++ b/src/cljc/xt_play/util.cljc @@ -0,0 +1,20 @@ +(ns xt-play.util + (:require #?(:clj [clojure.edn :as edn]))) + +#?(:clj + (def xt-version + (-> (slurp "deps.edn") + (edn/read-string) + (get-in [:deps 'com.xtdb/xtdb-core :mvn/version])))) + +#?(:clj + (def read-edn + (partial edn/read-string {:readers *data-readers*}))) + +(defn map-results->rows + [results] + (let [ks (keys (apply merge results))] + (into [(vec ks)] + (mapv (fn [row] + (mapv #(get row %) ks)) + results)))) diff --git a/src/cljs/xt_play/app.cljs b/src/cljs/xt_play/app.cljs index be069f9..8a29c02 100644 --- a/src/cljs/xt_play/app.cljs +++ b/src/cljs/xt_play/app.cljs @@ -1,20 +1,27 @@ (ns xt-play.app - (:require [lambdaisland.glogi :as log] + (:require [day8.re-frame.http-fx] ;; don't delete + [lambdaisland.glogi :as log] [lambdaisland.glogi.console :as glogi-console] [re-frame.core :as rf] [reagent.dom :as r-dom] - [xt-play.query-params :as query-params] - [xt-play.highlight :as hl] - [xt-play.tx-batch :as tx-batch] - [xt-play.query :as query] - [xt-play.client :as client] - [day8.re-frame.http-fx])) + [xt-play.components.highlight :as hl] + [xt-play.model.query :as query] + [xt-play.model.query-params :as query-params] + [xt-play.model.tx-batch :as tx-batch] + [xt-play.view :as view])) (glogi-console/install!) (log/set-levels {:glogi/root :info}) ;; Set a root logger level, this will be inherited by all loggers - ;; 'my.app.thing :trace ;; Some namespaces you might want detailed logging + +;; 'my.app.thing :trace ;; Some namespaces you might want detailed logging + +;; TODO: Special case existing txs +(defn- param-decode [s] + (let [txs (-> s js/atob js/JSON.parse (js->clj :keywordize-keys true))] + (->> txs + (map #(update % :system-time (fn [d] (when d (js/Date. d)))))))) (rf/reg-event-fx ::init @@ -29,14 +36,14 @@ (query/default type))} :dispatch [::tx-batch/init (if txs - (tx-batch/param-decode txs) + (param-decode txs) [(tx-batch/default type)])]}))) (defn ^:dev/after-load start! [] (log/info :start "start") (hl/setup) (rf/dispatch-sync [::init js/xt_version]) - (r-dom/render [client/app] (js/document.getElementById "app"))) + (r-dom/render [view/app] (js/document.getElementById "app"))) (defn ^:export init [] ;; init is called ONCE when the page loads diff --git a/src/cljs/xt_play/client.cljs b/src/cljs/xt_play/client.cljs deleted file mode 100644 index 5b8b64a..0000000 --- a/src/cljs/xt_play/client.cljs +++ /dev/null @@ -1,293 +0,0 @@ -(ns xt-play.client - (:require [xt-play.editor :as editor] - [xt-play.run :as run] - [xt-play.query-params :as query-params] - [xt-play.clipboard :as clipboard] - [xt-play.href :as href] - [xt-play.highlight :as hl] - [xt-play.tx-batch :as tx-batch] - [xt-play.query :as query] - [xt-play.dropdown :refer [dropdown]] - [clojure.string :as str] - [lambdaisland.glogi :as log] - [re-frame.core :as rf] - ["@heroicons/react/24/solid" :refer [ArrowUturnLeftIcon - PencilIcon - PlayIcon - XMarkIcon]] - ["@heroicons/react/24/outline" :refer [BookmarkIcon - CheckCircleIcon]])) - -(rf/reg-event-db - :hide-copy-tick - (fn [db _] - (dissoc db :copy-tick))) - -(rf/reg-event-fx - :copy-url - [(rf/inject-cofx ::href/get)] - (fn [{:keys [db href]} _] - {::clipboard/set {:text href} - :db (assoc db :copy-tick true) - :dispatch-later {:ms 800 :dispatch [:hide-copy-tick]}})) - -(rf/reg-sub - :copy-tick - :-> :copy-tick) - -(rf/reg-event-fx - :update-url - (fn [{:keys [db]} _] - {::query-params/set {:version (:version db) - :type (name (:type db)) - :txs (tx-batch/param-encode (tx-batch/list db)) - :query (js/btoa (:query db))}})) - -(rf/reg-event-fx - :dropdown-selection - (fn [{:keys [db]} [_ new-type]] - {:db (-> db - (assoc :type new-type) - (assoc :query (query/default new-type))) - :fx [[:dispatch [::tx-batch/init [(tx-batch/default new-type)]]] - [:dispatch [:update-url]]]})) - -(rf/reg-event-db - :set-query - (fn [db [_ query]] - (assoc db :query query))) - -(rf/reg-event-fx - :fx - (fn [_ [_ effects]] - {:fx effects})) - -(rf/reg-sub - :get-type - :-> :type) - -(rf/reg-sub - :query - :-> :query) - -(rf/reg-sub - :version - :-> :version) - -(defn language-dropdown [] - [dropdown {:items [{:value :sql :label "SQL"} - {:value :xtql :label "XTQL"}] - :selected @(rf/subscribe [:get-type]) - :on-click #(rf/dispatch [:dropdown-selection (:value %)]) - :label (case @(rf/subscribe [:get-type]) - :xtql "XTQL" - :sql "SQL")}]) - -(defn spinner [] - [:div "Loading..."]) - -(defn display-error [{:keys [exception message data]}] - [:div {:class "flex flex-col gap-2"} - [:div {:class "bg-red-100 border-l-4 border-red-500 text-red-700 p-4"} - [:p {:class "font-bold"} (str "Error: " exception)] - [:p {:class "whitespace-pre-wrap font-mono"} - (->> (str/split message #"(?:\r\n|\r|\n)") - (map #(do [:span %])) - (interpose [:br]))] - (when (seq data) - [:<> - [:p {:class "pt-2 font-semibold"} - "Data:"] - [:p (pr-str data)]])]]) - -(defn display-table [results type] - (when results - (let [all-keys (->> results - (mapcat keys) - (into #{}) - (sort))] - [:table {:class "table-auto w-full"} - [:thead - [:tr {:class "border-b"} - (for [k all-keys] - ^{:key k} - [:th {:class "text-left p-4"} - (-> k symbol str)])]] - [:tbody - (for [[i row] (map-indexed vector results)] - ^{:key i} - [:tr {:class "border-b"} - (for [k all-keys] - ^{:key k} - [:td {:class "text-left p-4"} - (let [value (get row k)] - (case type - :xtql [hl/code {:language "clojure"} - (pr-str value)] - :sql [hl/code {:language "json"} - (js/JSON.stringify (clj->js value))]))])])]]))) - -(defn title [& body] - (into [:h2 {:class "text-lg font-semibold"}] - body)) - -(defn button [opts & body] - (into [:button (merge {:class "bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded-sm"} - opts)] - body)) - -(defn run-button [] - [button {:class "bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded-sm" - :on-click #(rf/dispatch [::run/run])} - [:div {:class "flex flex-row gap-1 items-center"} - "Run" - [:> PlayIcon {:class "h-5 w-5"}]]]) - -(defn copy-button [] - (let [copy-tick @(rf/subscribe [:copy-tick])] - [:div {:class (str "p-2 flex flex-row gap-1 items-center select-none" - (when-not copy-tick - " hover:bg-gray-300 cursor-pointer")) - :disabled copy-tick - :on-click #(rf/dispatch-sync [:copy-url])} - (if-not copy-tick - [:<> - "Copy URL" - [:> BookmarkIcon {:class "h-5 w-5"}]] - [:<> - "Copied!" - [:> CheckCircleIcon {:class "h-5 w-5"}]])])) - -(defn header [] - [:header {:class "bg-gray-200 py-2 px-4"} - [:div {:class "container mx-auto flex flex-col md:flex-row items-center gap-1"} - [:div {:class "w-full flex flex-row items-center gap-4"} - [:a {:href "/"} - [:div {:class "flex flex-row items-center gap-1"} - [:img {:class "h-8" - :src "/public/images/xtdb-full-logo.svg"}] - [title "Play"]]] - [:span {:class "text-sm text-gray-400"} - @(rf/subscribe [:version])]] - [:div {:class "max-md:hidden flex-grow"}] - [:div {:class "w-full flex flex-row items-center gap-1 md:justify-end"} - [language-dropdown] - [:div {:class "md:hidden flex-grow"}] - [copy-button] - [run-button]]]]) - -(defn reset-system-time-button [id] - [:> ArrowUturnLeftIcon {:class "h-5 w-5 cursor-pointer" - :on-click #(rf/dispatch [:fx [[:dispatch [::tx-batch/assoc id :system-time nil]] - [:dispatch [:update-url]]]])}]) - -(defn input-system-time [id system-time] - ;; TODO: Show the picker when someone clicks the edit button - ;; https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/showPicker - [:input {:type "date" - :value (-> system-time .toISOString (str/split #"T") first) - :on-change #(rf/dispatch [:fx [[:dispatch [::tx-batch/assoc id :system-time (js/Date. (.. % -target -value))]] - [:dispatch [:update-url]]]]) - :max (-> (js/Date.) .toISOString (str/split #"T") first)}]) - -(defn edit-system-time-button [id] - [:> PencilIcon {:className "h-5 w-5 cursor-pointer" - :on-click #(rf/dispatch [:fx [[:dispatch [::tx-batch/assoc id :system-time (js/Date. (.toDateString (js/Date.)))]] - [:dispatch [:update-url]]]])}]) - -(defn single-transaction [{:keys [editor id]} {:keys [system-time txs]}] - [:div {:class "h-full flex flex-col"} - (when system-time - [:div {:class "flex flex-row justify-center items-center py-1 px-5 bg-gray-200"} - [input-system-time id system-time] - [reset-system-time-button id]]) - [editor {:class "border md:flex-grow min-h-36" - :source txs - :on-change #(rf/dispatch [:fx [[:dispatch [::tx-batch/assoc id :txs %]] - [:dispatch [:update-url]]]])}]]) - -(defn multiple-transactions [{:keys [editor]} tx-batches] - [:<> - (for [[id {:keys [system-time txs]}] tx-batches] - ^{:key id} - [:div {:class "flex flex-col"} - [:div {:class "flex flex-row justify-between items-center py-1 px-5 bg-gray-200"} - [:div {:class "w-full flex flex-row gap-2 justify-center items-center"} - (if (nil? system-time) - [:<> - [:div "Current Time"] - [edit-system-time-button id]] - [:<> - [input-system-time id system-time] - [reset-system-time-button id]])] - [:> XMarkIcon {:class "h-5 w-5 cursor-pointer" - :on-click #(rf/dispatch [:fx [[:dispatch [::tx-batch/delete id]] - [:dispatch [:update-url]]]])}]] - [editor {:class "border md:flex-grow min-h-36" - :source txs - :on-change #(rf/dispatch [:fx [[:dispatch [::tx-batch/assoc id :txs %]] - [:dispatch [:update-url]]]])}]])]) - -(defn transactions [{:keys [editor]}] - [:div {:class "mx-4 md:mx-0 md:ml-4 md:flex-1 flex flex-col"} - [:h2 "Transactions:"] - ; NOTE: The min-h-0 somehow makes sure the editor doesn't - ; overflow the flex container - [:div {:class "grow min-h-0 overflow-y-auto flex flex-col gap-2"} - (let [tx-batches @(rf/subscribe [::tx-batch/id-batch-pairs])] - (if (= 1 (count tx-batches)) - (let [[id batch] (first tx-batches)] - [single-transaction {:editor editor - :id id} - batch]) - [multiple-transactions {:editor editor} - tx-batches])) - [:div {:class "flex flex-row justify-center"} - [:button {:class "w-10 h-10 bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 rounded-full" - :on-click #(rf/dispatch [:fx [[:dispatch [::tx-batch/append tx-batch/blank]] - [:dispatch [:update-url]]]])} - "+"]]]]) - -(defn query [{:keys [editor]}] - [:div {:class "mx-4 md:mx-0 md:mr-4 md:flex-1 flex flex-col"} - [:h2 "Query:"] - [editor {:class "md:flex-grow h-full min-h-36 border" - :source @(rf/subscribe [:query]) - :on-change #(rf/dispatch [:fx [[:dispatch [:set-query %]] - [:dispatch [:update-url]]]])}]]) - -(defn results [] - [:section {:class "md:h-1/2 mx-4 flex flex-1 flex-col"} - [:h2 "Results:"] - [:div {:class "grow min-h-0 border p-2 overflow-auto"} - (if @(rf/subscribe [::run/loading?]) - [spinner] - (let [{::run/keys [results failure]} @(rf/subscribe [::run/results-or-failure])] - (if failure - [display-error failure] - (cond - (empty? results) "No results returned" - (every? empty? results) (str (count results) " empty row(s) returned") - :else [display-table results @(rf/subscribe [:get-type])]))))]]) - -(defn app [] - [:div {:class "flex flex-col h-dvh"} - [header] - ;; overflow-hidden fixes a bug where if an editor would have content that goes off the - ;; screen the whole page would scroll. - [:div {:class "py-2 flex-grow md:overflow-hidden h-full flex flex-col gap-2"} - [:section {:class "md:h-1/2 flex flex-col md:flex-row flex-1 gap-2"} - (let [editor (case @(rf/subscribe [:get-type]) - :xtql editor/clj-editor - :sql editor/sql-editor)] - [:<> - [transactions {:editor editor}] - [:hr {:class "md:hidden"}] - [query {:editor editor}] - [:div {:class "md:hidden flex flex-col items-center"} - [run-button]]])] - (when (or @(rf/subscribe [::run/loading?]) - @(rf/subscribe [::run/results?])) - [:<> - [:hr {:class "md:hidden"}] - [results]])]]) diff --git a/src/cljs/xt_play/dropdown.cljs b/src/cljs/xt_play/components/dropdown.cljs similarity index 98% rename from src/cljs/xt_play/dropdown.cljs rename to src/cljs/xt_play/components/dropdown.cljs index 087038d..b6ea2cc 100644 --- a/src/cljs/xt_play/dropdown.cljs +++ b/src/cljs/xt_play/components/dropdown.cljs @@ -1,4 +1,4 @@ -(ns xt-play.dropdown +(ns xt-play.components.dropdown (:require [reagent.core :as r])) (defn dropdown [{:keys [label selected items on-click]}] diff --git a/src/cljs/xt_play/editor.cljs b/src/cljs/xt_play/components/editor.cljs similarity index 82% rename from src/cljs/xt_play/editor.cljs rename to src/cljs/xt_play/components/editor.cljs index 3fed606..b1f21c3 100644 --- a/src/cljs/xt_play/editor.cljs +++ b/src/cljs/xt_play/components/editor.cljs @@ -1,4 +1,4 @@ -(ns xt-play.editor +(ns xt-play.components.editor (:require ["@codemirror/autocomplete" :refer [autocompletion]] ["@codemirror/commands" :refer [defaultKeymap history historyKeymap indentWithTab]] ["@codemirror/language" :refer [foldGutter syntaxHighlighting defaultHighlightStyle]] @@ -66,22 +66,23 @@ "xt$system_from" "xt$system_to"]} :upperCaseKeywords true})]) -(defn editor [{:keys [source extensions on-change] my-class :class}] +(defn editor [{:keys [source extensions] my-class :class :as opts}] [:div {:class my-class} - [:> CodeMirror {:value source - :extensions extensions - :basicSetup false - :className "h-full" - :on-change on-change}]]) + [:> CodeMirror (merge + {:value source + :extensions extensions + :basicSetup false + :className "h-full"} + (select-keys opts [:on-change :on-blur :on-focus]))]]) -(defn clj-editor [{:keys [source on-change] my-class :class}] - [editor {:source source - :extensions clj-extensions - :on-change on-change - :class my-class}]) +(defn clj-editor [opts] + [editor (merge opts {:extensions clj-extensions})]) -(defn sql-editor [{:keys [source on-change] my-class :class}] - [editor {:source source - :extensions sql-extensions - :on-change on-change - :class my-class}]) +(defn sql-editor [opts] + [editor (merge opts {:extensions sql-extensions})]) + +(defn default-editor [tx-type] + (case tx-type + :xtql clj-editor + :sql sql-editor + sql-editor)) diff --git a/src/cljs/xt_play/highlight.cljs b/src/cljs/xt_play/components/highlight.cljs similarity index 93% rename from src/cljs/xt_play/highlight.cljs rename to src/cljs/xt_play/components/highlight.cljs index e8d6a35..885a522 100644 --- a/src/cljs/xt_play/highlight.cljs +++ b/src/cljs/xt_play/components/highlight.cljs @@ -1,4 +1,4 @@ -(ns xt-play.highlight +(ns xt-play.components.highlight (:require ["highlight.js/lib/core" :as hljs] ["highlight.js/lib/languages/clojure" :as hljs-clojure] ["highlight.js/lib/languages/json" :as hljs-json])) diff --git a/src/cljs/xt_play/config.cljs b/src/cljs/xt_play/config.cljs new file mode 100644 index 0000000..86c416f --- /dev/null +++ b/src/cljs/xt_play/config.cljs @@ -0,0 +1,27 @@ +(ns xt-play.config) + +(def ^:private default-dml + "[:put-docs :docs {:xt/id 1 :foo \"bar\"}]") + +(def ^:private default-sql-insert + "INSERT INTO docs (_id, col1) VALUES (1, 'foo'); +INSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'};") + +(def default-transaction + {:sql default-sql-insert + :sql-beta default-sql-insert + :xtql default-dml}) + +(def config + {:show-beta? true}) + +(def tx-types + {:sql {:value :sql + :label "SQL"} + :xtql {:value :xtql + :label "XTQL"} + :sql-beta {:value :sql-beta + :label "Beta" + :beta? true}}) + + diff --git a/src/cljs/xt_play/model/client.cljs b/src/cljs/xt_play/model/client.cljs new file mode 100644 index 0000000..68b136a --- /dev/null +++ b/src/cljs/xt_play/model/client.cljs @@ -0,0 +1,75 @@ +(ns xt-play.model.client + (:require [re-frame.core :as rf] + [xt-play.config :as config] + [xt-play.model.clipboard :as clipboard] + [xt-play.model.href :as href] + [xt-play.model.query :as query] + [xt-play.model.query-params :as query-params] + [xt-play.model.tx-batch :as tx-batch])) + +(rf/reg-event-db + :hide-copy-tick + (fn [db _] + (dissoc db :copy-tick))) + +(rf/reg-event-fx + :copy-url + [(rf/inject-cofx ::href/get)] + (fn [{:keys [db href]} _] + {::clipboard/set {:text href} + :db (assoc db :copy-tick true) + :dispatch-later {:ms 800 :dispatch [:hide-copy-tick]}})) + +(rf/reg-sub + :copy-tick + :-> :copy-tick) + +(defn- param-encode [tx-batches] + (-> tx-batches clj->js js/JSON.stringify js/btoa)) + +(rf/reg-event-fx + :update-url + (fn [{:keys [db]} _] + {::query-params/set {:version (:version db) + :type (name (:type db)) + :txs (param-encode (tx-batch/batch-list db)) + :query (js/btoa (:query db))}})) + +(rf/reg-event-fx + :dropdown-selection + (fn [{:keys [db]} [_ new-type]] + {:db (-> db + (assoc :type new-type) + (assoc :query (query/default new-type))) + :fx [[:dispatch [::tx-batch/init [(tx-batch/default new-type)]]] + [:dispatch [:update-url]]]})) + +(rf/reg-event-db + :set-query + (fn [db [_ query]] + (assoc db :query query))) + +(rf/reg-event-fx + :fx ;; todo, use explicit reg-event-fxs + (fn [_ [_ effects]] + {:fx effects})) + +(rf/reg-sub + :get-type + :-> :type) + +(rf/reg-sub + :query + :-> :query) + +(rf/reg-sub + :version + :-> :version) + +(def items + (vec + (keep (fn [tx-type] + (when (or (:show-beta? config/config) + (not (:beta? tx-type))) + (select-keys tx-type [:value :label]))) + (vals config/tx-types)))) diff --git a/src/cljs/xt_play/clipboard.cljs b/src/cljs/xt_play/model/clipboard.cljs similarity index 91% rename from src/cljs/xt_play/clipboard.cljs rename to src/cljs/xt_play/model/clipboard.cljs index 1a93d79..2807f32 100644 --- a/src/cljs/xt_play/clipboard.cljs +++ b/src/cljs/xt_play/model/clipboard.cljs @@ -1,4 +1,4 @@ -(ns xt-play.clipboard +(ns xt-play.model.clipboard (:require [re-frame.core :as rf])) (rf/reg-fx ::set diff --git a/src/cljs/xt_play/href.cljs b/src/cljs/xt_play/model/href.cljs similarity index 84% rename from src/cljs/xt_play/href.cljs rename to src/cljs/xt_play/model/href.cljs index c0fdab2..ec6cb74 100644 --- a/src/cljs/xt_play/href.cljs +++ b/src/cljs/xt_play/model/href.cljs @@ -1,4 +1,4 @@ -(ns xt-play.href +(ns xt-play.model.href (:require [re-frame.core :as rf])) (rf/reg-cofx ::get diff --git a/src/cljs/xt_play/model/interval.cljs b/src/cljs/xt_play/model/interval.cljs new file mode 100644 index 0000000..88c73c0 --- /dev/null +++ b/src/cljs/xt_play/model/interval.cljs @@ -0,0 +1,37 @@ +(ns xt-play.model.interval + (:require [re-frame.core :as rf])) + +(defonce interval-handler + (let [live-intervals (atom {})] + (fn handler [{:keys [action id freq event]}] + (case action + :clean (doall (map #(handler {:action :end :id %1}) (keys @live-intervals))) + :start (swap! live-intervals assoc id (js/setInterval #(rf/dispatch event) freq)) + :end (do (js/clearInterval (get @live-intervals id)) + (swap! live-intervals dissoc id)))))) + +;; when this code is reloaded `:clean` existing intervals +(interval-handler {:action :clean}) + +(rf/reg-fx ::interval interval-handler) + +(rf/reg-event-fx + ::clean + (fn [_] + {::interval {:action :clean}})) + +(rf/reg-event-fx + ::start-editing + (fn [_] + {::interval {:action :start + :id :editing + :freq 1000 + :event [:update-url]}})) + +(rf/reg-event-fx + ::stop-editing + (fn [_] + {::interval {:action :end + :id :editing}})) + + diff --git a/src/cljs/xt_play/model/query.cljs b/src/cljs/xt_play/model/query.cljs new file mode 100644 index 0000000..eb3c542 --- /dev/null +++ b/src/cljs/xt_play/model/query.cljs @@ -0,0 +1,9 @@ +(ns xt-play.model.query) + +(def default-xtql-query "(from :docs [xt/id foo])") +(def default-sql-query "SELECT *, _valid_from FROM docs") + +(defn default [tx-type] + (if (= tx-type :xtql) + default-xtql-query + default-sql-query)) diff --git a/src/cljs/xt_play/query_params.cljs b/src/cljs/xt_play/model/query_params.cljs similarity index 75% rename from src/cljs/xt_play/query_params.cljs rename to src/cljs/xt_play/model/query_params.cljs index 2b9dc68..a20ab77 100644 --- a/src/cljs/xt_play/query_params.cljs +++ b/src/cljs/xt_play/model/query_params.cljs @@ -1,15 +1,17 @@ -(ns xt-play.query-params +(ns xt-play.model.query-params (:require [re-frame.core :as rf])) (defn get-query-params [] (->> (js/URLSearchParams. (.-search js/window.location)) - (map js->clj) - (map (fn [[k v]] [(keyword k) v])) + (map (fn [param] + (let [[k v] (js->clj param)] + {(keyword k) v}))) (into {}))) -(rf/reg-cofx ::get - (fn [cofx _] - (assoc cofx :query-params (get-query-params)))) +(rf/reg-cofx + ::get + (fn [cofx _] + (assoc cofx :query-params (get-query-params)))) (defn ->query-string [params] (let [search-params (js/URLSearchParams.)] diff --git a/src/cljs/xt_play/model/run.cljs b/src/cljs/xt_play/model/run.cljs new file mode 100644 index 0000000..1c26901 --- /dev/null +++ b/src/cljs/xt_play/model/run.cljs @@ -0,0 +1,66 @@ +(ns xt-play.model.run + (:require [ajax.core :as ajax] + [re-frame.core :as rf] + [xt-play.model.tx-batch :as tx-batch])) + +(defn- db-run-opts [{:keys [query type] :as db}] + (let [params {:tx-type type + :query query + :tx-batches (map #(update % :system-time (fn [d] (when d (.toISOString d)))) + (tx-batch/batch-list db))}] + {:method :post + :uri "/beta-db-run" + :params params + :timeout 3000 + :format (ajax/json-request-format) + :response-format (ajax/json-response-format {:keywords? true}) + :on-success [::request-success] + :on-failure [::request-failure]})) + +(defn run [db] + {:db (-> db + (assoc ::loading? true) + (dissoc ::failure ::results ::response?)) + :http-xhrio (db-run-opts db)}) + +(rf/reg-event-fx + ::run + (fn [{:keys [db]}] + (merge (run db) + {:dispatch [:update-url]}))) + +(rf/reg-event-db + ::request-success + (fn [db [_ results]] + (-> db + (dissoc ::loading?) + (assoc ::response? true) + (assoc ::results results)))) + +(rf/reg-event-db + ::request-failure + (fn [db [_ {:keys [response] :as _failure-map}]] + (-> db + (dissoc ::loading?) + (assoc ::failure response)))) + +(rf/reg-sub + ::results-or-failure + (fn [db] + (let [results (select-keys db [::results ::failure ::response?])] + (when-not (empty? results) + results)))) + +(rf/reg-sub + ::results? + :<- [::results-or-failure] + :-> boolean) + +(rf/reg-sub + ::loading? + :-> ::loading?) + +(comment + (require '[re-frame.db :as db]) + (def db @db/app-db) + (keys db)) diff --git a/src/cljs/xt_play/model/tx_batch.cljs b/src/cljs/xt_play/model/tx_batch.cljs new file mode 100644 index 0000000..3ff374f --- /dev/null +++ b/src/cljs/xt_play/model/tx_batch.cljs @@ -0,0 +1,82 @@ +(ns xt-play.model.tx-batch + (:require [re-frame.core :as rf] + [xt-play.config :as config])) + +;; Goals: +;; - Batches are in a consistent order for rendering +;; - Batches are accessed via a consistent key +;; - This means we can't just store a vector +;; - This is required because `xt-play.editor` doesn't support +;; updating the :source. +;; This makes deleting difficult. +;; +;; Given the above we store: +;; - A map of id -> batch +;; - A vector of ids +;; +;; In practice this looks like: +;; {:tx1 +;; :tx2 } +;; [:tx1 :tx2] +;; And as a user you'll mostly work with the map + +(defn- new-id! [] + (->> (gensym "tx") + name + (keyword :xt-play.model.tx-batch))) + +;; >> Events + +(rf/reg-event-db + ::init + (fn [db [_ initial-value]] + (let [ids (->> (repeatedly new-id!) (take (count initial-value)) vec)] + (-> db + (assoc ::list ids) + (assoc ::id->batch (zipmap ids initial-value)))))) + +(rf/reg-event-db + ::append + (fn [db [_ tx-batch]] + (let [id (new-id!)] + (-> db + (update ::list conj id) + (update ::id->batch assoc id tx-batch))))) + +(rf/reg-event-db + ::delete + (fn [db [_ id]] + (println "delete" id) + (-> db + (update ::list #(->> % (remove (fn [x] (= x id))) vec)) + (update ::id->batch dissoc id)))) + +(rf/reg-event-db + ::update + (fn [db [_ id f]] + (update-in db [::id->batch id] f))) + +(rf/reg-event-db + ::assoc + (fn [db [_ id k txs]] + (assoc-in db [::id->batch id k] txs))) + +;; >> Subscriptions + +(rf/reg-sub + ::id-batch-pairs + (fn [{batch-list ::list, batch-lookup ::id->batch} _] + (mapv #(vector % (get batch-lookup %)) batch-list))) + +;; >> API + +(defn batch-list + "Given a db return the list of batches in the correct order." + [{batch-list ::list, batch-lookup ::id->batch}] + (mapv #(get batch-lookup %) batch-list)) + +(def blank {:txs "" :system-time nil}) + +(defn default [tx-type] + {:system-time nil + :txs (config/default-transaction tx-type)}) diff --git a/src/cljs/xt_play/query.cljs b/src/cljs/xt_play/query.cljs deleted file mode 100644 index 6a0e7ee..0000000 --- a/src/cljs/xt_play/query.cljs +++ /dev/null @@ -1,11 +0,0 @@ -(ns xt-play.query) - -;; >> API - -(def default-xtql-query "(from :docs [xt/id foo])") -(def default-sql-query "SELECT *, _valid_from FROM docs") - -(defn default [type] - (case type - :xtql default-xtql-query - :sql default-sql-query)) diff --git a/src/cljs/xt_play/run.cljs b/src/cljs/xt_play/run.cljs deleted file mode 100644 index 15c045f..0000000 --- a/src/cljs/xt_play/run.cljs +++ /dev/null @@ -1,64 +0,0 @@ -(ns xt-play.run - (:require [xt-play.tx-batch :as tx-batch] - [clojure.string :as str] - [re-frame.core :as rf] - [ajax.core :as ajax])) - -(defn encode-txs [txs type] - (case type - :sql (->> (str/split txs #";") - (remove str/blank?) - (map #(do [:sql %])) - (vec) - (str)) - :xtql (str "[" txs "]"))) - -(defn remove-last-semicolon [s] - (str/replace s #";\s*$" "")) - -(defn encode-query [query type] - (case type - :sql (-> query remove-last-semicolon pr-str) - :xtql query)) - -(rf/reg-event-fx ::run - (fn [{:keys [db]} _] - {:db (-> db - (assoc ::loading? true) - (dissoc ::failure ::results)) - :http-xhrio {:method :post - :uri "/db-run" - :params {:tx-batches - (->> (tx-batch/list db) - (map #(update % :txs encode-txs (:type db))) - (map #(update % :system-time (fn [d] (when d (.toISOString d)))))) - :query (encode-query (:query db) (:type db))} - :timeout 3000 - :format (ajax/json-request-format) - :response-format (ajax/json-response-format {:keywords? true}) - :on-success [::request-success] - :on-failure [::request-failure]}})) - -(rf/reg-event-db ::request-success - (fn [db [_ results]] - (-> db - (dissoc ::loading?) - (assoc ::results results)))) - -(rf/reg-event-db ::request-failure - (fn [db [_ {:keys [response] :as _failure-map}]] - (-> db - (dissoc ::loading?) - (assoc ::failure response)))) - -(rf/reg-sub ::results-or-failure - :-> #(let [results (select-keys % [::results ::failure])] - (when-not (empty? results) - results))) - -(rf/reg-sub ::results? - :<- [::results-or-failure] - :-> boolean) - -(rf/reg-sub ::loading? - :-> ::loading?) diff --git a/src/cljs/xt_play/tx_batch.cljs b/src/cljs/xt_play/tx_batch.cljs deleted file mode 100644 index eba586b..0000000 --- a/src/cljs/xt_play/tx_batch.cljs +++ /dev/null @@ -1,101 +0,0 @@ -(ns xt-play.tx-batch - (:refer-clojure :exclude [list]) - (:require [re-frame.core :as rf])) - -;; Goals: -;; - Batches are in a consistent order for rendering -;; - Batches are accessed via a consistent key -;; - This means we can't just store a vector -;; - This is required because `xt-play.editor` doesn't support -;; updating the :source. -;; This makes deleting difficult. -;; -;; Given the above we store: -;; - A map of id -> batch -;; - A vector of ids -;; -;; In practice this looks like: -;; {:tx1 -;; :tx2 } -;; [:tx1 :tx2] -;; And as a user you'll mostly work with the map - -;; >> Utils - -(defn- new-id! [] - (->> (gensym "tx") name (keyword 'xt-play.tx-batch))) - - - -;; >> Events - -(rf/reg-event-db ::init - (fn [db [_ initial-value]] - (let [ids (->> (repeatedly new-id!) (take (count initial-value)) vec)] - (-> db - (assoc ::list ids) - (assoc ::id->batch (zipmap ids initial-value)))))) - -(rf/reg-event-db ::append - (fn [db [_ tx-batch]] - (let [id (new-id!)] - (-> db - (update ::list conj id) - (update ::id->batch assoc id tx-batch))))) - -(rf/reg-event-db ::delete - (fn [db [_ id]] - (println "delete" id) - (-> db - (update ::list #(->> % (remove (fn [x] (= x id))) vec)) - (update ::id->batch dissoc id)))) - -(rf/reg-event-db ::update - (fn [db [_ id f]] - (update-in db [::id->batch id] f))) - -(rf/reg-event-db ::assoc - (fn [db [_ id k txs]] - (assoc-in db [::id->batch id k] txs))) - - - -;; >> Subscriptions - -(rf/reg-sub ::id-batch-pairs - (fn [db _] - (let [id->batch (::id->batch db) - lst (::list db)] - (->> lst - (mapv (fn [id] [id (id->batch id)])))))) - - - -;; >> API - -(defn list - "Given a db return the list of batches in the correct order." - [db] - (let [ids (::list db) - id->batch (::id->batch db)] - (mapv id->batch ids))) - -(def blank {:txs "" :system-time nil}) - -(def default-dml "[:put-docs :docs {:xt/id 1 :foo \"bar\"}]") -(def default-sql-insert "INSERT INTO docs (_id, col1) VALUES (1, 'foo'); -INSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'};") -(defn default [type] - {:system-time nil - :txs (case type - :xtql default-dml - :sql default-sql-insert)}) - -(defn param-encode [tx-batches] - (-> tx-batches clj->js js/JSON.stringify js/btoa)) - -;; TODO: Special case existing txs -(defn param-decode [s] - (let [txs (-> s js/atob js/JSON.parse (js->clj :keywordize-keys true))] - (->> txs - (map #(update % :system-time (fn [d] (when d (js/Date. d)))))))) diff --git a/src/cljs/xt_play/view.cljs b/src/cljs/xt_play/view.cljs new file mode 100644 index 0000000..13d0721 --- /dev/null +++ b/src/cljs/xt_play/view.cljs @@ -0,0 +1,280 @@ +(ns xt-play.view + (:require ["@heroicons/react/24/outline" + :refer [BookmarkIcon CheckCircleIcon QuestionMarkCircleIcon]] + ["@heroicons/react/24/solid" + :refer [ArrowUturnLeftIcon PencilIcon PlayIcon XMarkIcon]] + [clojure.string :as str] + [re-frame.core :as rf] + [reagent.core :as r] + [xt-play.components.dropdown :refer [dropdown]] + [xt-play.components.editor :as editor] + [xt-play.components.highlight :as hl] + [xt-play.config :as config] + [xt-play.model.client :as model] + [xt-play.model.interval :as i] + [xt-play.model.run :as run] + [xt-play.model.tx-batch :as tx-batch])) + +;; Todo +;; - pull out components to own ns + +(defn- language-dropdown [tx-type] + [dropdown {:items model/items + :selected tx-type + :on-click #(rf/dispatch [:dropdown-selection (:value %)]) + :label (get-in config/tx-types [tx-type :label])}]) + +(defn- spinner [] [:div "Loading..."]) ;; todo spinners spin + +(defn- editor-update-opts [id source] + {:source source + :on-focus #(rf/dispatch [::i/start-editing]) + :on-change #(rf/dispatch (if (= :query id) + [:set-query %] + [::tx-batch/assoc id :txs %])) + :on-blur #(do + (rf/dispatch [:update-url]) + (rf/dispatch [::i/stop-editing]))}) + +(defn- display-error [{:keys [exception message data]}] + [:div {:class "flex flex-col gap-2"} + [:div {:class "bg-red-100 border-l-4 border-red-500 text-red-700 p-4"} + [:p {:class "font-bold"} (str "Error: " exception)] + [:p {:class "whitespace-pre-wrap font-mono"} + (->> (str/split message #"(?:\r\n|\r|\n)") + (map #(do [:span %])) + (interpose [:br]))] + (when (seq data) + [:<> + [:p {:class "pt-2 font-semibold"} + "Data:"] + [:p (pr-str data)]])]]) + +(defn- display-table [results tx-type] + (when results + [:table {:class "table-auto w-full"} + [:thead + [:tr {:class "border-b"} + (for [label (first results)] + ^{:key label} + [:th {:class "text-left p-4"} label])]] + [:tbody + (doall + (for [[i row] (map-indexed vector (rest results))] + ^{:key (str "row-" i)} + [:tr {:class "border-b"} + (doall + (for [[ii value] (map-indexed vector row)] + ^{:key (str "row-" i " col-" ii)} + [:td {:class "text-left p-4"} + (case @tx-type + :xtql + [hl/code {:language "clojure"} + (pr-str value)] + ;; default + [hl/code {:language "json"} + (js/JSON.stringify (clj->js value))])]))]))]])) + +(defn- title [& body] + (into [:h2 {:class "text-lg font-semibold"}] + body)) + +(defn- button [opts & body] + (into [:button (merge {:class "bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded-sm"} + opts)] + body)) + +(defn- run-button [] + [button {:class "bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded-sm" + :on-click #(rf/dispatch [::run/run])} + [:div {:class "flex flex-row gap-1 items-center"} + "Run" + [:> PlayIcon {:class "h-5 w-5"}]]]) + +(defn- copy-button [] + (let [copy-tick (rf/subscribe [:copy-tick])] + (fn [] + [:div {:class (str "p-2 flex flex-row gap-1 items-center select-none" + (when-not @copy-tick + " hover:bg-gray-300 cursor-pointer")) + :disabled @copy-tick + :on-click (fn [_] + ;; for more fluid typing, we only update url on + ;; blur. This means that sometimes the url hasn't got + ;; the latest updates from the app-db. Ensure that's + ;; not the case by updating before copying + (rf/dispatch-sync [:update-url]) + (rf/dispatch-sync [:copy-url]))} + + (if-not @copy-tick + [:<> + "Copy URL" + [:> BookmarkIcon {:class "h-5 w-5"}]] + [:<> + "Copied!" + [:> CheckCircleIcon {:class "h-5 w-5"}]])]))) + +(def ^:private logo + [:a {:href "/"} + [:div {:class "flex flex-row items-center gap-1"} + [:img {:class "h-8" + :src "/public/images/xtdb-full-logo.svg"}] + [title "Play"]]]) + +(defn- header [tx-type] + [:header {:class "max-md:sticky top-0 z-50 bg-gray-200 py-2 px-4"} + [:div {:class "container mx-auto flex flex-col md:flex-row items-center gap-1"} + [:div {:class "w-full flex flex-row items-center gap-4"} + logo + [:span {:class "text-sm text-gray-400"} + @(rf/subscribe [:version])]] + [:div {:class "max-md:hidden flex-grow"}] + [:div {:class "w-full flex flex-row items-center gap-1 md:justify-end"} + [language-dropdown tx-type] + [:div {:class "md:hidden flex-grow"}] + [copy-button] + [run-button]]]]) + +(def beta-copy + (str "We are currently testing a new SQL framework for XTDB Play which utilises more of XTDB 2.0s powerful new features. " + "Feel free to stick arround and have a play, but if you want to return to safty, select a different mode from the dropdown")) + +(defn- beta-banner [] + (when (:show-beta? config/config) + (let [expanded? (r/atom false)] + (fn [] + [:footer {:class "sticky max-md:hidden bottom-0 z-50 bg-red-200 py-2 px-4"} + [:div {:class "container text-red-900 mx-auto flex flex-col items-center gap-1 cursor-pointer" + :on-click #(swap! expanded? not)} + (if-not @expanded? + [:div {:class "flex items-center gap-1"} + "You are in beta mode." + [:> QuestionMarkCircleIcon {:class "h-5 w-5"}]] + [:p beta-copy])]])))) + +(defn- reset-system-time-button [id] + [:> ArrowUturnLeftIcon + {:class "h-5 w-5 cursor-pointer" + :on-click #(rf/dispatch + [:fx [[:dispatch [::tx-batch/assoc id :system-time nil]] + [:dispatch [:update-url]]]])}]) + +(defn- input-system-time [id system-time] + ;; TODO: Show the picker when someone clicks the edit button + ;; https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/showPicker + [:input {:type "date" + :value (-> system-time .toISOString (str/split #"T") first) + :on-change #(rf/dispatch [:fx [[:dispatch [::tx-batch/assoc id :system-time (js/Date. (.. % -target -value))]] + [:dispatch [:update-url]]]]) + :max (-> (js/Date.) .toISOString (str/split #"T") first)}]) + +(defn- edit-system-time-button [id] + [:> PencilIcon {:className "h-5 w-5 cursor-pointer" + :on-click #(rf/dispatch [:fx [[:dispatch [::tx-batch/assoc id :system-time (js/Date. (.toDateString (js/Date.)))]] + [:dispatch [:update-url]]]])}]) + +(defn- single-transaction [{:keys [editor id]} {:keys [system-time txs]}] + [:div {:class "h-full flex flex-col"} + (when system-time + [:div {:class "flex flex-row justify-center items-center py-1 px-5 bg-gray-200"} + [input-system-time id system-time] + [reset-system-time-button id]]) + [editor (merge + (editor-update-opts id txs) + {:class "border md:flex-grow min-h-36"})]]) + +(defn- multiple-transactions [{:keys [editor]} tx-batches] + [:<> + (for [[id {:keys [system-time txs]}] tx-batches] + ^{:key id} + [:div {:class "flex flex-col"} + [:div {:class "flex flex-row justify-between items-center py-1 px-5 bg-gray-200"} + [:div {:class "w-full flex flex-row gap-2 justify-center items-center"} + (if (nil? system-time) + [:<> + [:div "Current Time"] + [edit-system-time-button id]] + [:<> + [input-system-time id system-time] + [reset-system-time-button id]])] + [:> XMarkIcon {:class "h-5 w-5 cursor-pointer" + :on-click #(rf/dispatch [:fx [[:dispatch [::tx-batch/delete id]] + [:dispatch [:update-url]]]])}]] + [editor (merge + (editor-update-opts id txs) + {:class "border md:flex-grow min-h-36"})]])]) + +(defn- transactions [{:keys [editor]}] + [:div {:class "mx-4 md:mx-0 md:ml-4 md:flex-1 flex flex-col"} + [:h2 "Transactions:"] + ; NOTE: The min-h-0 somehow makes sure the editor doesn't + ; overflow the flex container + [:div {:class "grow min-h-0 overflow-y-auto flex flex-col gap-2"} + (let [tx-batches @(rf/subscribe [::tx-batch/id-batch-pairs])] + (if (= 1 (count tx-batches)) + (let [[id batch] (first tx-batches)] + [single-transaction {:editor editor + :id id} + batch]) + [multiple-transactions {:editor editor} + tx-batches])) + [:div {:class "flex flex-row justify-center"} + [:button {:class "w-10 h-10 bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 rounded-full" + :on-click #(rf/dispatch [:fx [[:dispatch [::tx-batch/append tx-batch/blank]] + [:dispatch [:update-url]]]])} + "+"]]]]) + +(defn- query [{:keys [editor]}] + [:div {:class "mx-4 md:mx-0 md:mr-4 md:flex-1 flex flex-col"} + [:h2 "Query:"] + [editor (merge + (editor-update-opts :query @(rf/subscribe [:query])) + {:class "md:flex-grow h-full min-h-36 border"})]]) + +(def ^:private initial-message [:p {:class "text-gray-400"} "Enter a query to see results"]) +(def ^:private no-results-message "No results returned") +(defn- empty-rows-message [results] (str (count results) " empty row(s) returned")) + +(defn- results [] + (let [tx-type (rf/subscribe [:get-type]) + loading? (rf/subscribe [::run/loading?]) + results-or-failure (rf/subscribe [::run/results-or-failure])] + (fn [] + [:section {:class "md:h-1/2 mx-4 flex flex-1 flex-col"} + [:h2 "Results:"] + [:div {:class "grow min-h-0 border p-2 overflow-auto"} + (if @loading? + [spinner] + (let [{::run/keys [results failure response?]} @results-or-failure] + (if failure + [display-error failure] + (cond + (not response?) initial-message + (empty? results) no-results-message + (every? empty? results) (empty-rows-message results) + :else + [display-table results tx-type]))))]]))) + +(def ^:private mobile-gap [:hr {:class "md:hidden"}]) + +(defn app [] + (let [tx-type (rf/subscribe [:get-type]) + loading? (rf/subscribe [::run/loading?]) + results? (rf/subscribe [::run/results?])] + (fn [] + [:div {:class "flex flex-col h-dvh"} + [header @tx-type] + ;; overflow-hidden fixes a bug where if an editor would have content that + ;; goes off the screen the whole page would scroll. + [:div {:class "py-2 flex-grow md:overflow-hidden h-full flex flex-col gap-2"} + [:section {:class "md:h-1/2 flex flex-col md:flex-row flex-1 gap-2"} + (let [ctx {:editor (editor/default-editor @tx-type)}] + [:<> + [transactions ctx] + mobile-gap + [query ctx] + mobile-gap])] + (when (or @loading? @results?) + [results])] + (when (= :sql-beta @tx-type) + [beta-banner])]))) diff --git a/test-resources/beta-sql-example-request.edn b/test-resources/beta-sql-example-request.edn new file mode 100644 index 0000000..6d50bca --- /dev/null +++ b/test-resources/beta-sql-example-request.edn @@ -0,0 +1,8 @@ +{:parameters + {:body + {:tx-type "sql-beta", + :tx-batches + [{:txs + "INSERT INTO docs (_id, col1) VALUES (1, 'foo');\nINSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'};", + :system-time nil}], + :query "SELECT * FROM docs"}}} diff --git a/test-resources/sql-example-request.edn b/test-resources/sql-example-request.edn new file mode 100644 index 0000000..4422fe6 --- /dev/null +++ b/test-resources/sql-example-request.edn @@ -0,0 +1,8 @@ +{:parameters + {:body + {:tx-type "sql", + :tx-batches + [{:txs + "INSERT INTO docs (_id, col1) VALUES (1, 'foo');\nINSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'};", + :system-time nil}], + :query "SELECT * FROM docs"}}} diff --git a/test-resources/sql-multi-transaction.edn b/test-resources/sql-multi-transaction.edn new file mode 100644 index 0000000..77aac48 --- /dev/null +++ b/test-resources/sql-multi-transaction.edn @@ -0,0 +1,27 @@ +{:parameters + {:body + {:tx-type "sql", + :tx-batches + [{:txs "INSERT INTO people (_id, name) VALUES (6, 'fred')", + :system-time "2024-01-01T00:00:00.000Z"} + {:txs + "INSERT INTO people (_id, name, likes)\nVALUES (9, 'bob', ['fishing', 3.14, {nested:'data'}])", + :system-time "2024-01-02T00:00:00.000Z"} + {:txs + "INSERT INTO people\nRECORDS {_id: 6,\n name: 'fred',\n info: {contact: [{loc: 'home',\n tel: '123'},\n {loc: 'work',\n tel: '456',\n registered: DATE '2024-01-01'}]}}", + :system-time "2024-01-03T00:00:00.000Z"} + {:txs "DELETE FROM people WHERE name = 'fred'", + :system-time "2024-01-04T00:00:00.000Z"} + {:txs + "INSERT INTO people (_id, name, info)\nSELECT _id, name, info\nFROM people FOR ALL SYSTEM_TIME\nWHERE _id = 6\nORDER BY _system_to DESC\nLIMIT 1", + :system-time "2024-01-05T00:00:00.000Z"} + {:txs "ERASE FROM people WHERE _id = 6", + :system-time "2024-01-06T00:00:00.000Z"} + {:txs + "INSERT INTO people (_id, name, favorite_color, _valid_from)\nVALUES (2, 'carol', 'blue', DATE '2023-01-01')", + :system-time "2024-01-07T00:00:00.000Z"} + {:txs + "INSERT INTO people (_id, name, favorite_color, _valid_from)\nVALUES (2, 'carol', 'red', DATE '2023-09-01')", + :system-time "2024-01-08T00:00:00.000Z"}], + :query + "SETTING DEFAULT VALID_TIME AS OF DATE '2023-10-01',\n DEFAULT SYSTEM_TIME AS OF DATE '2024-01-08'\nSELECT name, favorite_color , _valid_from, _system_from FROM people"}}} diff --git a/test-resources/xtql-example-request.edn b/test-resources/xtql-example-request.edn new file mode 100644 index 0000000..325e237 --- /dev/null +++ b/test-resources/xtql-example-request.edn @@ -0,0 +1,6 @@ +{:parameters + {:body + {:tx-type "xtql", + :tx-batches + [{:txs "[:put-docs :docs {:xt/id 1 :foo \"bar\"}]", :system-time nil}], + :query "(from :docs [xt/id foo])"}}} diff --git a/test/clj/xt_play/handler_test.clj b/test/clj/xt_play/handler_test.clj new file mode 100644 index 0000000..f7f3abc --- /dev/null +++ b/test/clj/xt_play/handler_test.clj @@ -0,0 +1,179 @@ +(ns xt-play.handler-test + (:require [clojure.edn :as edn] + [clojure.test :as t] + [next.jdbc :as jdbc] + [xt-play.handler :as h] + [xtdb.api :as xt])) + +;; todo: +;; [ ] test unhappy paths +;; [ ] test wider range of scenarios / formats +;; [ ] test to pipeline +;; [ ] assert format from client + +(defn- t-file [path] + (edn/read-string (slurp (format "test-resources/%s.edn" path)))) + +(t/deftest run-handler-test + (t/testing "xtql example returns expected results" + (t/is (= {:status 200, :body [[:foo :xt/id] ["bar" 1]]} + (h/run-handler (t-file "xtql-example-request"))))) + + (t/testing "sql example returns expected results" + (t/is (= {:status 200, + :body + [["_id" "col1" "col2"] + [2 "bar" " baz"] + [1 "foo" nil]]} + (h/run-handler (t-file "sql-example-request"))))) + + (t/testing "beta sql example returns expected results" + (t/is (= {:status 200, + :body + [[:_id :col1 :col2] + [2 "bar" " baz"] + [1 "foo" nil]]} + (h/run-handler (t-file "beta-sql-example-request")))))) + +(t/deftest run-handler-multi-transactions-test + (t/testing "multiple transactions in xtql" + (t/is (= {:status 200, :body [[:foo :xt/id] + ["baz" 2] + ["bar" 1]]} + (h/run-handler + (assoc-in + (t-file "xtql-example-request") + [:parameters :body :tx-batches] + [{:txs "[:put-docs :docs {:xt/id 1 :foo \"bar\"}]", + :system-time "2024-12-01T00:00:00.000Z"} + {:txs "[:put-docs :docs {:xt/id 2 :foo \"baz\"}]", + :system-time nil}]))))) + + (t/testing "multiple transacions on sql" + (t/is (= {:status 200, + :body + [["_id" "col1" "col2" "_valid_from"] + [2 "bar" " baz" #time/zoned-date-time "2024-12-02T00:00Z[UTC]"] + [1 "foo" nil #time/zoned-date-time "2024-12-01T00:00Z[UTC]"]]} + (h/run-handler + (-> (t-file "sql-example-request") + (assoc-in + [:parameters :body :tx-batches] + [{:txs "INSERT INTO docs (_id, col1) VALUES (1, 'foo');", + :system-time "2024-12-01T00:00:00.000Z"} + {:txs "INSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'};", + :system-time "2024-12-02T00:00:00.000Z"}]) + (assoc-in + [:parameters :body :query] + "SELECT *, _valid_from FROM docs")))))) + + (t/testing "beta sql can run multiple txs" + (t/is (= {:status 200, + :body + [[:_id :col1 :col2 :_valid_from] + [2 "bar" " baz" #inst "2024-12-02T00:00:00.000000000-00:00"] + [1 "foo" nil #inst "2024-12-01T00:00:00.000000000-00:00"]]} + (h/run-handler + (-> (t-file "beta-sql-example-request") + (assoc-in + [:parameters :body :tx-batches] + [{:txs "INSERT INTO docs (_id, col1) VALUES (1, 'foo');", + :system-time "2024-12-01T00:00:00.000Z"} + {:txs "INSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'};", + :system-time "2024-12-02T00:00:00.000Z"}]) + (assoc-in + [:parameters :body :query] + "SELECT *, _valid_from FROM docs"))))))) + +(t/deftest beta-sql-run-features + (t/testing "Column order is maintained" + (t/is (= {:status 200, + :body [[:_id :a :b :c :d :e :f :g :h :j] + [1 2 3 4 5 6 7 8 9 10]]} + (h/run-handler + (assoc-in + (t-file "beta-sql-example-request") + [:parameters :body :tx-batches] + [{:txs "INSERT INTO docs RECORDS {_id: 1, a: 2, b: 3, c: 4, d: 5, e: 6, f: 7, g: 8, h: 9, j: 10}" + :system-time nil}]))))) + + (t/testing "execute payload is not mutated" + (let [txs (atom [])] + (with-redefs [jdbc/execute! (fn [_conn statement & args] + (swap! txs conj statement))] + (h/run-handler (t-file "beta-sql-example-request")) + (t/is + (= [[(str "INSERT INTO docs (_id, col1) VALUES (1, 'foo');\n" + "INSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'};")] + ["SELECT * FROM docs"]] + @txs))))) + + (t/testing "xt submit-tx sql payload is reformatted" + (let [txs (atom [])] + (def txs txs) + (with-redefs [xt/submit-tx (fn [_node tx & args] + (swap! txs conj tx))] + (h/run-handler (t-file "sql-example-request")) + (t/is + (= [[[:sql "INSERT INTO docs (_id, col1) VALUES (1, 'foo')"] + [:sql "\nINSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'}"]]] + @txs)))))) + +(t/deftest sql-says-carol-is-red-test + (t/testing "XTDB docs example for sql https://docs.xtdb.com/quickstart/sql-overview.html" + (t/is (= {:status 200, + :body + [["name" "favorite_color" "_valid_from" "_system_from"] + ["carol" + "red" + #time/zoned-date-time "2023-09-01T00:00Z[UTC]" + #time/zoned-date-time "2024-01-08T00:00Z[UTC]"]]} + (h/run-handler (t-file "sql-multi-transaction"))))) + + (t/testing "Bob still likes fishing - don't determine columns based on the first row" + (t/is (= {:status 200, + :body + [["_id" "favorite_color" "name" "likes"] + [2 "red" "carol" nil] + [9 nil "bob" ["fishing" 3.14 {"nested" "data"}]]]} + (h/run-handler + (assoc-in + (t-file "sql-multi-transaction") + [:parameters :body :query] + "SELECT * FROM people")))))) + +(t/deftest beta-sql-says-carol-is-red-test + (t/testing "XTDB docs example for sql https://docs.xtdb.com/quickstart/sql-overview.html" + (t/is (= {:status 200, + :body + [[:name :favorite_color :_valid_from :_system_from] + ["carol" + "red" + #inst "2023-08-31T23:00:00.000000000-00:00" + #inst "2024-01-08T00:00:00.000000000-00:00"]]} + (h/run-handler (assoc-in + (t-file "sql-multi-transaction") + [:parameters :body :tx-type] + "sql-beta"))))) + + (t/testing "Bob still likes fishing - don't determine columns based on the first row" + (t/is (= {:status 200, + :body + [[:_id :favorite_color :info :likes :name] + [2 "red" nil nil "carol"] + [9 nil nil ["fishing" 3.14 {:nested "data"}] "bob"]]} + (h/run-handler + (-> (t-file "sql-multi-transaction") + (assoc-in [:parameters :body :query] "SELECT * FROM people") + (assoc-in [:parameters :body :tx-type] "sql-beta"))))))) + +(t/deftest docs-run + (t/testing "docs run returns map results" + (t/is + (= {:status 200, + :body + [{"_id" 2, "favorite_color" "red", "name" "carol"} + {"_id" 9, "likes" ["fishing" 3.14 {"nested" "data"}], "name" "bob"}]} + (h/docs-run-handler + (-> (t-file "sql-multi-transaction") + (assoc-in [:parameters :body :query] "SELECT * FROM people"))))))) diff --git a/test/cljs/xt_play/model/run_test.cljs b/test/cljs/xt_play/model/run_test.cljs new file mode 100644 index 0000000..9977ca6 --- /dev/null +++ b/test/cljs/xt_play/model/run_test.cljs @@ -0,0 +1,48 @@ +(ns xt-play.model.run-test + (:require + [cljs.test :as t] + [xt-play.model.run :as model] + [xt-play.model.tx-batch :as batch])) + +(def app-db + {:version "2.0.0-beta3", + :type :sql, + :query "SELECT *, _valid_from FROM docs", + ::batch/list [::batch/tx5], + ::batch/id->batch + {::batch/tx5 + {:system-time #inst "2024-12-05T00:00:00.000-00:00", + :txs + "INSERT INTO docs (_id, col1) VALUES (1, 'foo');\nINSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'};"}}, + ::model/response? true, + ::model/results + [["_id" "col1" "col2" "_valid_from"] + [2 "bar" " baz" "2024-12-06T16:29:11.264541Z"] + [1 "foo" nil "2024-12-06T16:29:11.264541Z"]]}) + +(t/deftest run-test + (let [{app-db-after :db + opts :http-xhrio} (model/run app-db)] + + (t/testing "app-db is in expected state" + (t/is (= (-> app-db + (assoc ::model/loading? true) + (dissoc ::model/response? ::model/results)) + app-db-after))) + + (t/testing "request is as expected" + (t/is (= {:method :post, + :uri "/beta-db-run", + :params + {:tx-type :sql, + :query "SELECT *, _valid_from FROM docs", + :tx-batches + [{:system-time "2024-12-05T00:00:00.000Z", + :txs + "INSERT INTO docs (_id, col1) VALUES (1, 'foo');\nINSERT INTO docs RECORDS {_id: 2, col1: 'bar', col2:' baz'};"}]}, + :timeout 3000, + :on-success [::model/request-success], + :on-failure [::model/request-failure]} + (dissoc opts :format :response-format)))))) + +(t/run-tests)