From 217e75145fcfa84fb45a09d589b5e3d210f3a3a0 Mon Sep 17 00:00:00 2001 From: Johanna Antonelli Date: Tue, 3 Dec 2024 17:59:48 +0000 Subject: [PATCH] Beta use pgwire for sql fixes #42 fixes #19 Handle vector row results Move data manipulation to server Remove beta button Add beta banner Add more logging Handle todos Add handler tests Refactor Reorg file structure Stop shadowing clojure.core/list Refactor Re-frame improvements fixes #44 Add cljs run test Tidy up Fix query not updating Fix nested data formats Stay backwards compatible for docs Remove confusing system time abstraction Tidy up Fix mobile footer and unstick non-mobile header Standardise where we update-url on editor updates Add update url interval Make cljs config more config Fix cljs tests --- .gitignore | 1 + deps.edn | 5 +- shadow-cljs.edn | 8 +- src/clj/xt_play/config.clj | 9 + src/clj/xt_play/handler.clj | 127 +++----- src/clj/xt_play/transactions.clj | 117 +++++++ src/clj/xt_play/view.clj | 31 ++ src/cljc/xt_play/util.cljc | 20 ++ src/cljs/xt_play/app.cljs | 27 +- src/cljs/xt_play/client.cljs | 293 ------------------ .../xt_play/{ => components}/dropdown.cljs | 2 +- src/cljs/xt_play/{ => components}/editor.cljs | 35 ++- .../xt_play/{ => components}/highlight.cljs | 2 +- src/cljs/xt_play/config.cljs | 27 ++ src/cljs/xt_play/model/client.cljs | 75 +++++ src/cljs/xt_play/{ => model}/clipboard.cljs | 2 +- src/cljs/xt_play/{ => model}/href.cljs | 2 +- src/cljs/xt_play/model/interval.cljs | 37 +++ src/cljs/xt_play/model/query.cljs | 9 + .../xt_play/{ => model}/query_params.cljs | 14 +- src/cljs/xt_play/model/run.cljs | 66 ++++ src/cljs/xt_play/model/tx_batch.cljs | 82 +++++ src/cljs/xt_play/query.cljs | 11 - src/cljs/xt_play/run.cljs | 64 ---- src/cljs/xt_play/tx_batch.cljs | 101 ------ src/cljs/xt_play/view.cljs | 280 +++++++++++++++++ test-resources/beta-sql-example-request.edn | 8 + test-resources/sql-example-request.edn | 8 + test-resources/sql-multi-transaction.edn | 27 ++ test-resources/xtql-example-request.edn | 6 + test/clj/xt_play/handler_test.clj | 179 +++++++++++ test/cljs/xt_play/model/run_test.cljs | 48 +++ 32 files changed, 1138 insertions(+), 585 deletions(-) create mode 100644 src/clj/xt_play/config.clj create mode 100644 src/clj/xt_play/transactions.clj create mode 100644 src/clj/xt_play/view.clj create mode 100644 src/cljc/xt_play/util.cljc delete mode 100644 src/cljs/xt_play/client.cljs rename src/cljs/xt_play/{ => components}/dropdown.cljs (98%) rename src/cljs/xt_play/{ => components}/editor.cljs (82%) rename src/cljs/xt_play/{ => components}/highlight.cljs (93%) create mode 100644 src/cljs/xt_play/config.cljs create mode 100644 src/cljs/xt_play/model/client.cljs rename src/cljs/xt_play/{ => model}/clipboard.cljs (91%) rename src/cljs/xt_play/{ => model}/href.cljs (84%) create mode 100644 src/cljs/xt_play/model/interval.cljs create mode 100644 src/cljs/xt_play/model/query.cljs rename src/cljs/xt_play/{ => model}/query_params.cljs (75%) create mode 100644 src/cljs/xt_play/model/run.cljs create mode 100644 src/cljs/xt_play/model/tx_batch.cljs delete mode 100644 src/cljs/xt_play/query.cljs delete mode 100644 src/cljs/xt_play/run.cljs delete mode 100644 src/cljs/xt_play/tx_batch.cljs create mode 100644 src/cljs/xt_play/view.cljs create mode 100644 test-resources/beta-sql-example-request.edn create mode 100644 test-resources/sql-example-request.edn create mode 100644 test-resources/sql-multi-transaction.edn create mode 100644 test-resources/xtql-example-request.edn create mode 100644 test/clj/xt_play/handler_test.clj create mode 100644 test/cljs/xt_play/model/run_test.cljs 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)