├── dev-resources └── sql │ ├── find-planets.sql │ ├── find-planets-by-name.sql │ ├── find-planets-by-mass.sql │ └── create-planets.sql ├── .gitignore ├── Makefile ├── dev └── scratchpad.clj ├── src └── inquery │ ├── pred.cljc │ └── core.cljc ├── CHANGELOG.md ├── deps.edn ├── README.md └── test └── inquery └── test └── core.clj /dev-resources/sql/find-planets.sql: -------------------------------------------------------------------------------- 1 | -- find all planets 2 | select * from planets; 3 | -------------------------------------------------------------------------------- /dev-resources/sql/find-planets-by-name.sql: -------------------------------------------------------------------------------- 1 | -- find planets by name 2 | select * from planets where name like :name 3 | -------------------------------------------------------------------------------- /dev-resources/sql/find-planets-by-mass.sql: -------------------------------------------------------------------------------- 1 | -- find planets under a certain mass 2 | select * from planets where mass <= :max-mass 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | .repl* 7 | dev/resources/public/js/* 8 | figwheel_server.log 9 | build.xml 10 | doo-index.html 11 | *.jar 12 | *.class 13 | /.lein-* 14 | /.nrepl-port 15 | *.iml 16 | /.idea 17 | /.lein-repl-history 18 | /.nrepl-history 19 | .cljs_rhino_repl/ 20 | out/ 21 | .cpcache/ 22 | .rebel_readline_history 23 | .DS_Store 24 | test/.DS_Store 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean jar outdated tag install deploy tree test repl 2 | 3 | clean: 4 | rm -rf target 5 | 6 | jar: tag 7 | clojure -A:jar 8 | 9 | outdated: 10 | clojure -M:outdated 11 | 12 | tag: 13 | clojure -A:tag 14 | 15 | install: jar 16 | clojure -A:install 17 | 18 | deploy: jar 19 | clojure -A:deploy 20 | 21 | tree: 22 | clojure -Xdeps tree 23 | 24 | test: 25 | clojure -X:test :patterns '[".*test.*"]' 26 | 27 | ## does not work with "-M"s ¯\_(ツ)_/¯ 28 | repl: 29 | clojure -A:dev -A:test -A:repl 30 | -------------------------------------------------------------------------------- /dev-resources/sql/create-planets.sql: -------------------------------------------------------------------------------- 1 | -- create planets 2 | drop table if exists planets; 3 | create table planets (id bigint auto_increment, name varchar, mass decimal); 4 | 5 | insert into planets (name, mass) values ('Mercury', 330.2), 6 | ('Venus', 4868.5), 7 | ('Earth', 5973.6), 8 | ('Mars', 641.85), 9 | ('Jupiter', 1898600), 10 | ('Saturn', 568460), 11 | ('Uranus', 86832), 12 | ('Neptune', 102430), 13 | ('Pluto', 13.105); 14 | -------------------------------------------------------------------------------- /dev/scratchpad.clj: -------------------------------------------------------------------------------- 1 | (ns scratchpad 2 | (:require [inquery.core :as q] 3 | [jdbc.core :as jdbc])) 4 | 5 | (def dbspec 6 | {:subprotocol "h2" ;; dbspec would usually come from config.end / consul / etc.. 7 | :subname "file:/tmp/solar"}) 8 | 9 | (def queries 10 | (q/make-query-map #{:create-planets ;; set of queries would usually come from config.edn / consul / etc.. 11 | :find-planets 12 | :find-planets-by-mass})) 13 | 14 | ;; sample functions using "funcool/clojure.jdbc" 15 | 16 | (defn with-db [db-spec f] 17 | (with-open [conn (jdbc/connection db-spec)] 18 | (f conn))) 19 | 20 | (defn execute [db-spec query] 21 | (with-db db-spec 22 | (fn [conn] (jdbc/execute conn query)))) 23 | 24 | (defn fetch 25 | ([db-spec query] 26 | (fetch db-spec query {})) 27 | ([db-spec query params] 28 | (with-db db-spec 29 | (fn [conn] (jdbc/fetch conn 30 | (q/with-params query params)))))) 31 | -------------------------------------------------------------------------------- /src/inquery/pred.cljc: -------------------------------------------------------------------------------- 1 | (ns inquery.pred 2 | #?(:clj (:require [clojure.string :as s]) 3 | :cljs (:require [clojure.string :as s]))) 4 | 5 | (defn check-pred [pred] 6 | (if (fn? pred) 7 | (.invoke pred) 8 | (throw (ex-info "predicate should be a function" {:instead-got pred})))) 9 | 10 | (defn value? [v] 11 | (or (number? v) 12 | (seq v))) 13 | 14 | (defn- remove-start-op 15 | [q op] 16 | (if (and (value? q) 17 | (value? op)) 18 | (when (s/starts-with? (s/upper-case q) 19 | (s/upper-case op)) 20 | (subs q (count op))) 21 | q)) 22 | 23 | (defn remove-start-ops [qpart] 24 | "removes an op from part of the query that starts with it (i.e. AND, OR, etc.) 25 | 26 | 'and dog = :bow' => ' dog = :bow' 27 | 'or dog = :bow' => ' dog = :bow' 28 | 'xor dog = :bow' => 'xor dog = :bow' ;; xor is not a SQL op 29 | " 30 | (let [ops #{"or" "and"}] ;; TODO: add more when/iff needed 31 | (or (some #(remove-start-op qpart %) ops) 32 | qpart))) 33 | 34 | (defn with-prefix [prefix qpart] 35 | (case (s/lower-case (or prefix "")) 36 | "where" (if (seq qpart) 37 | (str prefix " " (remove-start-ops qpart)) 38 | qpart) 39 | (str prefix " " qpart))) 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.1.22 2 | 3 | * add support for LocalDate SqlParam (thanks to [@arichiardi](https://github.com/arichiardi)) 4 | 5 | # 0.1.21 6 | 7 | * add [SqlParam](https://github.com/tolitius/inquery?tab=readme-ov-file#type-safety) 8 | * w/ built in batching for `insert` and `update` queries 9 | 10 | # 0.1.20 11 | 12 | * add support for #inst and #uuid literals (thanks to [@arichiardi](https://github.com/arichiardi)) 13 | 14 | # 0.1.19 15 | 16 | * add `esc` function 17 | 18 | # 0.1.17 19 | 20 | * have "`seq->update-vals`" return 'null's for nils 21 | 22 | ```clojure 23 | (deftest should-sub-batch-upserts 24 | (testing "should correctly sub params for values in batch upserts" 25 | (let [q "insert into planets (\"id\", \"system\", \"planet\") values :planets" 26 | sub (fn [q vs] (-> q (q/with-params {:planets {:as (q/seq->update-vals vs)}})))] 27 | (is (= (str "insert into planets (\"id\", \"system\", \"planet\") " 28 | "values " 29 | "('42','solar','earth')," 30 | "('34',null,'saturn')," ;; <<<<<< before the null here would be '' 31 | "('28','','pluto')") 32 | (sub q [[42 "solar" "earth"] 33 | [34 nil "saturn"] 34 | [28 "" "pluto"]])))))) 35 | ``` 36 | 37 | # 0.1.16 38 | 39 | * fix: same prefix params 40 | 41 | ```clojure 42 | (deftest should-sub-starts-with-params 43 | (testing "should correctly sub params that start with the same prefix" 44 | (let [q "select * from planets where moons = :super-position-moons and mass <= :super and name = :super-position" 45 | sub (fn [q m] (-> q (q/with-params m)))] 46 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit'" 47 | (sub q {:super 42 48 | :super-position "quettabit" 49 | :super-position-moons "up-and-down"})))))) 50 | ``` 51 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | 3 | :deps {} ;; no deps 4 | 5 | :aliases {:dev {:extra-paths ["dev" "dev-resources"] 6 | :extra-deps {funcool/clojure.jdbc {:mvn/version "0.9.0"} 7 | com.h2database/h2 {:mvn/version "1.4.195"}}} 8 | :test {:extra-paths ["test" "test/resources"] 9 | :extra-deps {io.github.cognitect-labs/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" 10 | :sha "e7660458ce25bc4acb4ccc3e2415aae0a4907198"}} 11 | :main-opts ["-m" "cognitect.test-runner"] 12 | :exec-fn cognitect.test-runner.api/test} 13 | :repl {:extra-paths ["test" "test/resources"] 14 | :extra-deps {nrepl/nrepl {:mvn/version "0.7.0"} 15 | cider/cider-nrepl {:mvn/version "0.22.4"} 16 | com.bhauman/rebel-readline {:mvn/version "0.1.4"}} 17 | :main-opts [;; "-e" "(require 'dev)(in-ns 'dev)" 18 | "-m" "nrepl.cmdline" "--middleware" "[cider.nrepl/cider-middleware]" 19 | "-i" "-f" "rebel-readline.main/-main"]} 20 | :outdated {:extra-deps {olical/depot {:mvn/version "2.0.1"}} 21 | :main-opts ["-m" "depot.outdated.main" "-a" "outdated"]} 22 | :tag {:extra-deps {tolitius/tag {:mvn/version "0.1.7"}} 23 | :main-opts ["-m" "tag.core" "tolitius/inquery" "vanilla SQL with params for clojure/script"]} 24 | :jar {:extra-deps {seancorfield/depstar {:mvn/version "1.1.128"}} 25 | :extra-paths ["target/about"] 26 | :main-opts ["-m" "hf.depstar.jar" "target/inquery.jar" "--exclude" "clojure/core/specs/alpha.*"]} 27 | :deploy {:extra-deps {deps-deploy/deps-deploy {:mvn/version "RELEASE"}} 28 | :main-opts ["-m" "deps-deploy.deps-deploy" "deploy" "target/inquery.jar"]} 29 | :install {:extra-deps {deps-deploy/deps-deploy {:mvn/version "RELEASE"}} 30 | :main-opts ["-m" "deps-deploy.deps-deploy" "install" "target/inquery.jar"]}}} 31 | -------------------------------------------------------------------------------- /src/inquery/core.cljc: -------------------------------------------------------------------------------- 1 | (ns inquery.core 2 | #?(:clj (:require [clojure.java.io :as io] 3 | [clojure.string :as s] 4 | [inquery.pred :as pred]) 5 | :cljs (:require [cljs.nodejs :as node] 6 | [clojure.string :as s] 7 | [inquery.pred :as pred]))) 8 | 9 | (defprotocol SqlParam 10 | "safety first" 11 | (to-sql-string [this] "trusted type will be SQL'ized")) 12 | 13 | (extend-protocol SqlParam 14 | nil 15 | (to-sql-string [_] "null") 16 | 17 | String 18 | (to-sql-string [s] (str "'" (s/replace s "'" "''") "'")) 19 | 20 | Number 21 | (to-sql-string [n] (str n)) 22 | 23 | Boolean 24 | (to-sql-string [b] (str b)) 25 | 26 | #?(:clj java.util.UUID :cljs cljs.core.UUID) 27 | (to-sql-string [u] (str "'" u "'")) 28 | 29 | #?(:clj java.time.Instant) 30 | #?(:clj (to-sql-string [i] (str "'" (.toString i) "'"))) 31 | 32 | #?(:clj java.time.LocalDate) 33 | #?(:clj (to-sql-string [d] (str "'" (.toString d) "'"))) 34 | 35 | #?(:clj java.util.Date :cljs js/Date) 36 | (to-sql-string [d] (str "'" d "'")) 37 | 38 | clojure.lang.Keyword 39 | (to-sql-string [k] (str "'" (name k) "'")) 40 | 41 | #?(:clj clojure.lang.IPersistentCollection :cljs cljs.core.ICollection) 42 | (to-sql-string [coll] 43 | (if (seq coll) 44 | (str "(" 45 | (->> coll 46 | (map (fn [v] 47 | (if (nil? v) 48 | "null" 49 | (if (= v "") 50 | "''" 51 | (to-sql-string v))))) 52 | (s/join ",")) 53 | ")") 54 | "(null)")) 55 | 56 | Object 57 | (to-sql-string [o] 58 | (throw (ex-info "not sure about safety of this type. if needed, implement the SqlParam protocol" 59 | {:value o, :type (type o)})))) 60 | 61 | #?(:cljs 62 | (defn read-query [path qname] 63 | (let [fname (str path "/" qname ".sql")] 64 | (try 65 | (.toString 66 | (.readFileSync (node/require "fs") fname)) 67 | (catch :default e 68 | (throw (js/Error. (str "can't find query file to load: \"" fname "\": " e)))))))) 69 | 70 | #?(:clj 71 | (defn read-query [path qname] 72 | (let [fname (str path "/" qname ".sql") 73 | sql (io/resource fname)] 74 | (if sql 75 | (slurp sql) 76 | (throw (RuntimeException. (str "can't find query file to load: \"" fname "\""))))))) 77 | 78 | (defn make-query-map 79 | ([names] 80 | (make-query-map names {:path "sql"})) 81 | ([names {:keys [path]}] 82 | (into {} 83 | (for [qname names] 84 | (->> qname 85 | name 86 | (read-query path) 87 | (vector qname)))))) 88 | 89 | (defn treat-as? [k v] 90 | (if (map? v) 91 | (if (contains? v :as) ;; TODO: later if more opts check validate all: (#{:as :foo :bar} op) 92 | v 93 | (throw (ex-info "invalid query substitution option. supported options are: #{:as}" 94 | {:key k :value v}))))) 95 | 96 | (defn escape-params [params mode] 97 | (let [esc# (case mode 98 | :ansi #(str \" (s/replace % "\"" "\"\"") \") ;; 99 | :mysql #(str \` (s/replace % "`" "``") \`) ;; TODO: maybe later when returning a sqlvec 100 | :mssql #(str \[ (s/replace % "]" "]]") \]) ;; 101 | :don't identity 102 | identity)] 103 | (into {} (for [[k v] params] 104 | [k (cond 105 | (treat-as? k v) (-> v :as str) ;; "no escape" 106 | (= mode :don't) (str v) ;; in :don't naughty mode, bypass the protocol 107 | :else (if (= v "") ;; empty string needs special treatment 108 | "''" 109 | (esc# (to-sql-string v))))])))) 110 | 111 | (defn seq->batch-params 112 | "convert seq of seqs to updatable values (to use in SQL batch updates): 113 | 114 | => ;; xs 115 | [[#uuid 'c7a344f2-0243-4f92-8a96-bfc7ee482a9c' 116 | #uuid 'b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa'] 117 | [#uuid '3236ebed-8248-4b07-a37e-c64c0a062247' 118 | #uuid 'b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa']] 119 | 120 | => (seq->batch-params xs) 121 | 122 | ('c7a344f2-0243-4f92-8a96-bfc7ee482a9c','b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa'), 123 | ('3236ebed-8248-4b07-a37e-c64c0a062247','b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa') 124 | 125 | to be able to plug them into something like: 126 | 127 | update test as t set 128 | column_a = c.column_a 129 | from (values 130 | ('123', 1), << here 131 | ('345', 2) << is a batch of values 132 | ) as c(column_b, column_a) 133 | where c.column_b = t.column_b; 134 | " 135 | [xs] 136 | (->> xs 137 | (map to-sql-string) 138 | (interpose ",") 139 | (apply str))) 140 | 141 | (defn with-preds 142 | "* adds predicates to the query 143 | * if \"where\" needs to be prefixed add {:prefix \"where\"} 144 | * will remove any SQL ops that a predicate starts with in case it needs to go right after \"where\" 145 | * if none preds matched with \"where\" prefix the prefix won't be used 146 | 147 | => (q/with-preds \"select foo from bar where this = that\" 148 | {#(= 42 42) \"and dog = :bow\" 149 | #(= 2 5) \"and cat = :moo\" 150 | #(= 28 28) \"or cow = :moo\"}) 151 | 152 | => \"select foo from bar 153 | where this = that 154 | and dog = :bow 155 | or cow = :moo\" 156 | 157 | ;; or with \"where\": 158 | 159 | => (q/with-preds \"select foo from bar\" 160 | {#(= 42 42) \"and dog = :bow\" 161 | #(= 2 5) \"and cat = :moo\" 162 | #(= 28 28) \"or cow = :moo\"} 163 | {:prefix \"where\"}) 164 | 165 | => \"select foo from bar 166 | where dog = :bow 167 | or cow = :moo\" 168 | " 169 | ([query pred-map] 170 | (with-preds query pred-map {})) 171 | ([query pred-map {:keys [prefix]}] 172 | (->> pred-map 173 | (filter (comp pred/check-pred first)) 174 | vals 175 | (interpose " ") 176 | (apply str) 177 | (pred/with-prefix prefix) 178 | (str query " ")))) 179 | 180 | (defn- compare-key-length [k1 k2] 181 | (let [length #(-> % str count)] 182 | (compare [(length k2) k2] 183 | [(length k1) k1]))) 184 | 185 | (defn with-params 186 | ([query params] 187 | (with-params query params {})) 188 | ([query params {:keys [esc]}] 189 | (if (seq query) 190 | (let [eparams (->> (escape-params params esc) 191 | (into (sorted-map-by compare-key-length)))] 192 | (reduce-kv (fn [q k v] 193 | (s/replace q (str k) v)) 194 | query eparams)) 195 | (throw (ex-info "can't execute an empty query" {:params params}))))) 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | ;; legacy corner 208 | 209 | (defn- legacy-val->sql 210 | "legacy batch updates need special handling of numbers" 211 | [v] 212 | (cond 213 | (nil? v) "null" 214 | (= v "") "''" 215 | (number? v) (str "'" v "'") 216 | :else (to-sql-string v))) 217 | 218 | (defn seq->in-params 219 | ;; convert seqs to IN params: i.e. [1 "2" 3] => "('1','2','3')" 220 | ;; no longer needed as it is handled by the SqlParam protocol 221 | ;; kept here because legacy relies on all values to be quoted: ('1','2','3'), even though some of them are numbers: [1 "2" 3] 222 | [xs] 223 | (as-> xs $ 224 | (mapv legacy-val->sql $) 225 | (s/join "," $) 226 | (str "(" $ ")"))) 227 | 228 | (defn seq->update-vals 229 | ;; replace with seq->batch-params 230 | ;; kept here because legacy relies on all values to be quoted: ('1','2','3'), even though some of them are numbers: [1 "2" 3] 231 | [xs] 232 | "convert seq of seqs to updatable values (to use in SQL batch updates): 233 | 234 | => ;; xs 235 | [[#uuid 'c7a344f2-0243-4f92-8a96-bfc7ee482a9c' 236 | #uuid 'b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa'] 237 | [#uuid '3236ebed-8248-4b07-a37e-c64c0a062247' 238 | #uuid 'b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa']] 239 | 240 | => (seq->update-vals xs) 241 | 242 | ('c7a344f2-0243-4f92-8a96-bfc7ee482a9c','b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa'), 243 | ('3236ebed-8248-4b07-a37e-c64c0a062247','b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa') 244 | 245 | to be able to plug them into something like: 246 | 247 | update test as t set 248 | column_a = c.column_a 249 | from (values 250 | ('123', 1), << here 251 | ('345', 2) << is a batch of values 252 | ) as c(column_b, column_a) 253 | where c.column_b = t.column_b; 254 | " 255 | [xs] 256 | (->> xs 257 | (map seq->in-params) 258 | (interpose ",") 259 | (apply str))) 260 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # inquery 2 | 3 | vanilla SQL with params for Clojure/Script 4 | 5 | * no DSL 6 | * no comments parsing 7 | * no namespace creations 8 | * no defs / defqueries 9 | * no dependencies 10 | * no edn SQL 11 | 12 | just "read SQL with `:params`" 13 | 14 | [![Clojars Project](https://clojars.org/tolitius/inquery/latest-version.svg)](http://clojars.org/tolitius/inquery) 15 | 16 | - [why](#why) 17 | - [using inquery](#using-inquery) 18 | - [escaping](#escaping) 19 | - [dynamic queries](#dynamic-queries) 20 | - [type safety](#type-safety) 21 | - [batch upserts](#batch-upserts) 22 | - [ClojureScript](#clojurescript) 23 | - [scratchpad](#scratchpad) 24 | - [license](#license) 25 | 26 | ## why 27 | 28 | SQL is a great language, it is very expressive and exremely well optimized and supported by "SQL" databases. 29 | it needs no wrappers. it should live in its pure SQL form. 30 | 31 | `inquery` does two things: 32 | 33 | * reads SQL files 34 | * substitutes params at runtime 35 | 36 | Clojure APIs cover all the rest 37 | 38 | ## using inquery 39 | 40 | `inquery` is about SQL: it _does not_ require or force a particular JDBC library or a database. 41 | 42 | But to demo an actual database conversation, this example will use "[funcool/clojure.jdbc](http://funcool.github.io/clojure.jdbc/latest/)" to speak to 43 | a sample [H2](http://www.h2database.com/html/main.html) database since both of them are great. 44 | 45 | There is nothing really to do other than to bring the queries into a map with a `make-query-map` function: 46 | 47 | ```clojure 48 | $ make repl 49 | 50 | => (require '[inquery.core :as q] 51 | '[jdbc.core :as jdbc]) 52 | ``` 53 | 54 | `dbspec` along with a `set of queries` would usually come from `config.edn` / consul / etc : 55 | 56 | ```clojure 57 | => (def dbspec {:subprotocol "h2" 58 | :subname "file:/tmp/solar"}) 59 | 60 | => (def queries (q/make-query-map #{:create-planets 61 | :find-planets 62 | :find-planets-by-mass 63 | :find-planets-by-name})) 64 | ``` 65 | 66 | `inquiry` by default will look under `sql/*` path for queries. In this case "[dev-resources](dev-resources)" is in a classpath: 67 | 68 | ``` 69 | ▾ dev-resources/sql/ 70 |   create-planets.sql 71 |   find-planets-by-mass.sql 72 |   find-planets-by-name.sql 73 |   find-planets.sql 74 | ``` 75 | 76 | Ready to roll, let's create some planets: 77 | 78 | ```clojure 79 | => (with-open [conn (jdbc/connection dbspec)] 80 | (jdbc/execute conn (:create-planets queries))) 81 | ``` 82 | 83 | check out the solar system: 84 | 85 | ```clojure 86 | => (with-open [conn (jdbc/connection dbspec)] 87 | (jdbc/fetch conn (:find-planets queries))) 88 | 89 | [{:id 1, :name "Mercury", :mass 330.2M} 90 | {:id 2, :name "Venus", :mass 4868.5M} 91 | {:id 3, :name "Earth", :mass 5973.6M} 92 | {:id 4, :name "Mars", :mass 641.85M} 93 | {:id 5, :name "Jupiter", :mass 1898600M} 94 | {:id 6, :name "Saturn", :mass 568460M} 95 | {:id 7, :name "Uranus", :mass 86832M} 96 | {:id 8, :name "Neptune", :mass 102430M} 97 | {:id 9, :name "Pluto", :mass 13.105M}] 98 | ``` 99 | 100 | find all the planets with mass less or equal to the mass of Earth: 101 | 102 | ```clojure 103 | => (with-open [conn (jdbc/connection dbspec)] 104 | (jdbc/fetch conn (-> (:find-planets-by-mass queries) 105 | (q/with-params {:max-mass 5973.6})))) 106 | 107 | [{:id 1, :name "Mercury", :mass 330.2M} 108 | {:id 2, :name "Venus", :mass 4868.5M} 109 | {:id 3, :name "Earth", :mass 5973.6M} 110 | {:id 4, :name "Mars", :mass 641.85M} 111 | {:id 9, :name "Pluto", :mass 13.105M}] 112 | ``` 113 | 114 | which planet is the most `art`sy: 115 | 116 | ```clojure 117 | => (with-open [conn (jdbc/connection dbspec)] 118 | (jdbc/fetch conn (-> (:find-planets-by-name queries) 119 | (q/with-params {:name "%art%"})))) 120 | 121 | [{:id 3, :name "Earth", :mass 5973.6M}] 122 | ``` 123 | 124 | ### escaping 125 | 126 | by default inquery will "SQL escape" all the parameters that need to be substituted in a query. 127 | 128 | in case you need to _not_ escape the params inquery has options to not escape the whole query with `{:esc :don't}`: 129 | 130 | ```clojure 131 | => (with-open [conn (jdbc/connection dbspec)] 132 | (jdbc/fetch conn (-> (:find-planets-by-name queries) 133 | (q/with-params {:name "%art%"} 134 | {:esc :don't})))) 135 | 136 | ``` 137 | 138 | or per individual parameter with `{:as val}`: 139 | 140 | ```clojure 141 | => (with-open [conn (jdbc/connection dbspec)] 142 | (jdbc/fetch conn (-> (:find-planets-by-name queries) 143 | (q/with-params {:name {:as ""} 144 | :mass 42})))) 145 | ``` 146 | 147 | #### things to note about escaping 148 | 149 | `nil`s are converted to "null": 150 | 151 | ```clojure 152 | => (-> "name = :name" (q/with-params {:name nil})) 153 | "name = null" 154 | ``` 155 | 156 | `{:as nil}` or `{:as ""}` are "as is", so it will be replaced with an empty string: 157 | 158 | ```clojure 159 | => (-> "name = :name" (q/with-params {:name {:as nil}})) 160 | "name = " 161 | 162 | => (-> "name = :name" (q/with-params {:name {:as ""}})) 163 | "name = " 164 | ``` 165 | 166 | `""` will become a "SQL empty string": 167 | 168 | ```clojure 169 | => (-> "name = :name" (q/with-params {:name ""})) 170 | "name = ''" 171 | ``` 172 | 173 | see [tests](test/inquery/test/core.clj) for more examples. 174 | 175 | ### dynamic queries 176 | 177 | inquery can help out with some runtime decision making to build SQL predicates. 178 | 179 | `with-preds` function takes a map of `{pred-fn sql-predicate}`.
180 | for each "true" predicate function its `sql-predicate` will be added to the query: 181 | 182 | ```clojure 183 | => (q/with-preds "select planet from solar_system where this = that" 184 | {#(= 42 42) "and type = :type"}) 185 | 186 | "select planet from solar_system where this = that and type = :type" 187 | ``` 188 | 189 | ```clojure 190 | => (q/with-preds "select planet from solar_system where this = that" 191 | {#(= 42 42) "and type = :type" 192 | #(= 28 34) "and size < :max-size"}) 193 | 194 | "select planet from solar_system where this = that and type = :type" 195 | ``` 196 | 197 | if both predicates are true, both will be added: 198 | 199 | ```clojure 200 | => (q/with-preds "select planet from solar_system where this = that" 201 | {#(= 42 42) "and type = :type" 202 | #(= 28 28) "and size < :max-size"}) 203 | 204 | "select planet from solar_system where this = that and type = :type and size < :max-size" 205 | ``` 206 | 207 | some queries don't come with `where` clause, for these cases `with-preds` takes a prefix: 208 | 209 | ```clojure 210 | => (q/with-preds "select planet from solar_system" 211 | {#(= 42 42) "and type = :type" 212 | #(= 28 34) "and size < :max-size"} 213 | {:prefix "where"}) 214 | 215 | "select planet from solar_system where type = :type" 216 | ``` 217 | 218 | developer will know the (first part of the) query, so this decision is not "hardcoded". 219 | 220 | ```clojure 221 | => (q/with-preds "select planet from solar_system" 222 | {#(= 42 42) "and type = :type" 223 | #(= 34 34) "and size < :max-size"} 224 | {:prefix "where"}) 225 | 226 | "select planet from solar_system where type = :type and size < :max-size" 227 | ``` 228 | 229 | in case none of the predicates are true, `"where"` prefix won't be used: 230 | 231 | ```clojure 232 | => (q/with-preds "select planet from solar_system" 233 | {#(= 42 -42) "and type = :type" 234 | #(= 34 28) "and size < :max-size"} 235 | {:prefix "where"}) 236 | 237 | "select planet from solar_system" 238 | ``` 239 | 240 | ## type safety 241 | 242 | ### sql parameters 243 | 244 | inquery uses a type protocol `SqlParam` to safely convert clojure/script values to sql strings: 245 | 246 | ```clojure 247 | (defprotocol SqlParam 248 | "safety first" 249 | (to-sql-string [this] "trusted type will be SQL'ized")) 250 | ``` 251 | 252 | it: 253 | 254 | * prevents sql injection 255 | * properly handles various data types 256 | * is extensible for custom types 257 | 258 | common types are handled out of the box: 259 | 260 | ```clojure 261 | (q/to-sql-string nil) ;; => "null" 262 | (q/to-sql-string "earth") ;; => "'earth'" 263 | (q/to-sql-string "pluto's moon") ;; => "'pluto''s moon'" ;; note proper escaping 264 | (q/to-sql-string 42) ;; => "42" 265 | (q/to-sql-string true) ;; => "true" 266 | (q/to-sql-string :jupiter) ;; => "'jupiter'" 267 | (q/to-sql-string [1 2 nil "mars"]) ;; => "(1,2,null,'mars')" 268 | (q/to-sql-string #uuid "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") ;; => "'f81d4fae-7dec-11d0-a765-00a0c91e6bf6'" 269 | (q/to-sql-string #inst "2023-01-15T12:34:56Z") ;; => "'2023-01-15T12:34:56Z'" 270 | (q/to-sql-string (java.util.Date.)) ;; => "'Wed Mar 26 09:42:17 EDT 2025'" 271 | ``` 272 | 273 | ### custom types 274 | 275 | you can extend `SqlParam` protocol to handle custom types: 276 | 277 | ```clojure 278 | (defrecord Planet [name mass]) 279 | 280 | (extend-protocol inquery.core/SqlParam 281 | Planet 282 | (to-sql-string [planet] 283 | (str "'" (:name planet) " (" (:mass planet) " x 10^24 kg)'"))) 284 | 285 | (q/to-sql-string (->Planet "neptune" 102)) ;; => "'neptune (102 x 10^24 kg)'" 286 | ``` 287 | 288 | ### its built in 289 | 290 | no need to call "`to-sql-string`" of course, inquery does it internally: 291 | 292 | ```clojure 293 | ;; find planets discovered during specific time range with certain composition types 294 | (let [query "SELECT * FROM planets 295 | WHERE discovery_date BETWEEN :start_date AND :end_date 296 | AND name NOT IN :excluded_planets 297 | AND composition_type IN :allowed_types 298 | AND is_habitable = :habitable 299 | AND discoverer_id = :discoverer" 300 | params {:start_date (Instant/parse "2020-01-01T00:00:00Z") 301 | :end_date (java.util.Date.) 302 | :excluded_planets ["mercury" "venus" "earth"] 303 | :allowed_types [:rocky :gas-giant :ice-giant] 304 | :habitable true 305 | :discoverer (UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6")}] 306 | 307 | (q/with-params query params)) 308 | ``` 309 | ```clojure 310 | ;; => "SELECT * FROM planets 311 | ;; WHERE discovery_date BETWEEN '2020-01-01T00:00:00Z' AND 'Wed Mar 26 09:48:32 EDT 2025' 312 | ;; AND name NOT IN ('mercury','venus','earth') 313 | ;; AND composition_type IN ('rocky','gas-giant','ice-giant') 314 | ;; AND is_habitable = true 315 | ;; AND discoverer_id = 'f81d4fae-7dec-11d0-a765-00a0c91e6bf6'" 316 | ``` 317 | 318 | ## batch upserts 319 | 320 | inquery provides functions to safely convert collections for batch operations: 321 | 322 | * `seq->batch-params` - converts a sequence of sequences to a string suitable for batch inserts/updates 323 | * `seq->update-vals` - legacy version that quotes all values (even numbers) 324 | 325 | ```clojure 326 | ;; using seq->batch-params for modern batch operations 327 | ;; (perfect for cataloging newly discovered exoplanets) 328 | (q/seq->batch-params [[42 "earth" 5973.6] 329 | ["34" nil "saturn"]]) 330 | ;; => "(42,'earth',5973.6),('34',null,'saturn')" 331 | 332 | ;; safe handling of UUIDs, timestamps, and other complex types 333 | ;; (for when you need to record celestial events) 334 | (let [uuid1 (UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") 335 | timestamp (Instant/parse "2023-01-15T12:34:56Z")] 336 | (q/seq->batch-params [[uuid1 "planet" "earth" timestamp]])) 337 | ;; => "('f81d4fae-7dec-11d0-a765-00a0c91e6bf6','planet','earth','2023-01-15T12:34:56Z')" 338 | ``` 339 | 340 | and the real SQL example: 341 | 342 | ```clojure 343 | ;; batch insert new celestial bodies with mixed data types 344 | (let [query "INSERT INTO celestial_bodies 345 | (id, name, type, mass, discovery_date, is_confirmed) 346 | VALUES :bodies" 347 | 348 | ;; collection of [id, name, type, mass, date, confirmed?] 349 | bodies [[#uuid "c7a344f2-0243-4f92-8a96-bfc7ee482a9c" 350 | "kepler-186f" 351 | :exoplanet 352 | 4.7 353 | #inst "2014-04-17T00:00:00Z" 354 | true] 355 | 356 | [#uuid "3236ebed-8248-4b07-a37e-c64c0a062247" 357 | "toi-700d" 358 | :exoplanet 359 | 1.72 360 | #inst "2020-01-07T00:00:00Z" 361 | true] 362 | 363 | [#uuid "b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa" 364 | "proxima centauri b" 365 | :exoplanet 366 | 1.27 367 | #inst "2016-08-24T00:00:00Z" 368 | nil]]] 369 | 370 | (q/with-params query {:bodies {:as (q/seq->batch-params bodies)}})) 371 | ``` 372 | ```clojure 373 | ;; => "INSERT INTO celestial_bodies 374 | ;; (id, name, type, mass, discovery_date, is_confirmed) 375 | ;; VALUES 376 | ;; ('c7a344f2-0243-4f92-8a96-bfc7ee482a9c','kepler-186f','exoplanet',4.7,'2014-04-17T00:00:00Z',true), 377 | ;; ('3236ebed-8248-4b07-a37e-c64c0a062247','toi-700d','exoplanet',1.72,'2020-01-07T00:00:00Z',true), 378 | ;; ('b29bc806-7db1-4e0c-93f7-fe5ee38ad1fa','proxima centauri b','exoplanet',1.27,'2016-08-24T00:00:00Z',null)" 379 | ``` 380 | 381 | ## ClojureScript 382 | 383 | ```clojure 384 | $ lumo -i src/inquery/core.cljc --repl 385 | Lumo 1.2.0 386 | ClojureScript 1.9.482 387 | Docs: (doc function-name-here) 388 | Exit: Control+D or :cljs/quit or exit 389 | 390 | cljs.user=> (ns inquery.core) 391 | ``` 392 | 393 | depending on how a resource path is setup, an optional parameter `{:path "..."}` 394 | could help to specify the path to queries: 395 | 396 | ```clojure 397 | inquery.core=> (def queries 398 | (make-query-map #{:create-planets 399 | :find-planets 400 | :find-planets-by-mass} 401 | {:path "dev-resources/sql"})) 402 | #'inquery.core/queries 403 | ``` 404 | 405 | ```clojure 406 | inquery.core=> (print queries) 407 | 408 | {:create-planets -- create planets 409 | drop table if exists planets; 410 | create table planets (id bigint auto_increment, name varchar, mass decimal); 411 | 412 | insert into planets (name, mass) values ('Mercury', 330.2), 413 | ('Venus', 4868.5), 414 | ('Earth', 5973.6), 415 | ('Mars', 641.85), 416 | ('Jupiter', 1898600), 417 | ('Saturn', 568460), 418 | ('Uranus', 86832), 419 | ('Neptune', 102430), 420 | ('Pluto', 13.105); 421 | , :find-planets -- find all planets 422 | select * from planets; 423 | , :find-planets-by-mass -- find planets under a certain mass 424 | select * from planets where mass <= :max-mass 425 | } 426 | ``` 427 | 428 | ```clojure 429 | inquery.core=> (-> queries 430 | :find-planets-by-mass 431 | (with-params {:max-mass 5973.6})) 432 | 433 | -- find planets under a certain mass 434 | select * from planets where mass <= 5973.6 435 | ``` 436 | 437 | ## scratchpad 438 | 439 | development [scratchpad](dev/scratchpad.clj) with sample shortcuts: 440 | 441 | ```clojure 442 | $ make repl 443 | 444 | => (require '[scratchpad :as sp :refer [dbspec queries]]) 445 | 446 | => (sp/execute dbspec (:create-planets queries)) 447 | 448 | => (sp/fetch dbspec (:find-planets queries)) 449 | 450 | [{:id 1, :name "Mercury", :mass 330.2M} 451 | {:id 2, :name "Venus", :mass 4868.5M} 452 | {:id 3, :name "Earth", :mass 5973.6M} 453 | {:id 4, :name "Mars", :mass 641.85M} 454 | {:id 5, :name "Jupiter", :mass 1898600M} 455 | {:id 6, :name "Saturn", :mass 568460M} 456 | {:id 7, :name "Uranus", :mass 86832M} 457 | {:id 8, :name "Neptune", :mass 102430M} 458 | {:id 9, :name "Pluto", :mass 13.105M}] 459 | 460 | => (sp/fetch dbspec (:find-planets-by-mass queries) {:max-mass 5973.6}) 461 | 462 | [{:id 1, :name "Mercury", :mass 330.2M} 463 | {:id 2, :name "Venus", :mass 4868.5M} 464 | {:id 3, :name "Earth", :mass 5973.6M} 465 | {:id 4, :name "Mars", :mass 641.85M} 466 | {:id 9, :name "Pluto", :mass 13.105M}] 467 | ``` 468 | 469 | ## license 470 | 471 | Copyright © 2025 tolitius 472 | 473 | Distributed under the Eclipse Public License either version 1.0 or (at 474 | your option) any later version. 475 | -------------------------------------------------------------------------------- /test/inquery/test/core.clj: -------------------------------------------------------------------------------- 1 | (ns inquery.test.core 2 | (:require [inquery.core :as q] 3 | [clojure.edn :as edn] 4 | [clojure.pprint :as pp] 5 | [clojure.test :refer :all]) 6 | (:import java.time.Instant 7 | java.time.LocalDate 8 | java.util.Date 9 | java.util.UUID)) 10 | 11 | (deftest should-sub-params 12 | (testing "should sub params" 13 | (let [q "select * from planets where mass <= :max-mass and name = :name" 14 | sub (fn [q m] (-> q (q/with-params m)))] 15 | (is (= "select * from planets where mass <= 42 and name = 'mars'" (sub q {:max-mass 42 :name "mars"}))) 16 | (is (= "select * from planets where mass <= 42 and name = null" (sub q {:max-mass 42 :name nil}))) 17 | (is (= "select * from planets where mass <= null and name = ''" (sub q {:max-mass nil :name ""}))) 18 | (is (= "select * from planets where mass <= 42 and name = " (sub q {:max-mass 42 :name {:as ""}}))) 19 | (is (= "select * from planets where mass <= 42 and name = 42" (sub q {:max-mass {:as 42} :name {:as "42"}}))) 20 | (is (= "select * from planets where mass <= and name = ''''''" (sub q {:max-mass {:as nil} :name "''"}))) 21 | (is (= "select * from planets where mass <= '' and name = ''''''" (sub q {:max-mass {:as "''"} :name "''"}))) 22 | (is (= "select * from planets where mass <= and name = ''" (-> q (q/with-params {:max-mass {:as nil} :name "''"} 23 | {:esc :don't}))))) 24 | (testing "param is #uuid" 25 | (let [q "select * from planets where id = :planet-id" 26 | sub (fn [q m] (-> q (q/with-params m))) 27 | planet-id (random-uuid)] 28 | (is (= (str "select * from planets where id = '" planet-id "'") 29 | (sub q {:planet-id planet-id}))))) 30 | 31 | (testing "param is #time/instant" 32 | (let [q "select * from planets where id = :imploded-at" 33 | sub (fn [q m] (-> q (q/with-params m))) 34 | imploded-at (Instant/now)] 35 | (is (= (str "select * from planets where id = '" (.toString imploded-at) "'") 36 | (sub q {:imploded-at imploded-at}))))))) 37 | 38 | (deftest should-sub-starts-with-params 39 | (testing "should correctly sub params that start with the same prefix" 40 | (let [q "select * from planets where moons = :super-position-moons and mass <= :super and name = :super-position" 41 | sub (fn [q m] (-> q (q/with-params m)))] 42 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit'" 43 | (sub q {:super 42 44 | :super-position "quettabit" 45 | :super-position-moons "up-and-down"}))) 46 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit'" 47 | (sub q {:super-position "quettabit" 48 | :super 42 49 | :super-position-moons "up-and-down"}))) 50 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit'" 51 | (sub q {:super-position "quettabit" 52 | :super-position-moons "up-and-down" 53 | :super 42}))) 54 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit'" 55 | (sub q {:super-position-moons "up-and-down" 56 | :super-position "quettabit" 57 | :super 42}))) 58 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit'" 59 | (sub q {:super-position-moons "up-and-down" 60 | :super 42 61 | :super-position "quettabit"}))))) 62 | 63 | (testing "should correctly sub params that start with the same prefix, even when some of the keys have the same length" 64 | (let [q "select * from planets where moons = :super-position-moons and mass <= :super and name = :super-position and orbital_offset = :orbital-offset and orbital_offset_looks = :orbital-offset-looks" 65 | sub (fn [q m] (-> q (q/with-params m)))] 66 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit' and orbital_offset = 'acute' and orbital_offset_looks = 'wobbly'" 67 | (sub q {:super-position-moons "up-and-down" 68 | :orbital-offset-looks "wobbly" 69 | :orbital-offset "acute" 70 | :super-position "quettabit" 71 | :super 42}))) 72 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit' and orbital_offset = 'acute' and orbital_offset_looks = 'wobbly'" 73 | (sub q {:orbital-offset-looks "wobbly" 74 | :super-position-moons "up-and-down" 75 | :super-position "quettabit" 76 | :orbital-offset "acute" 77 | :super 42}))) 78 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit' and orbital_offset = 'acute' and orbital_offset_looks = 'wobbly'" 79 | (sub q {:super 42 80 | :super-position "quettabit" 81 | :super-position-moons "up-and-down" 82 | :orbital-offset "acute" 83 | :orbital-offset-looks "wobbly"}))) 84 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit' and orbital_offset = 'acute' and orbital_offset_looks = 'wobbly'" 85 | (sub q {:super-position "quettabit" 86 | :super-position-moons "up-and-down" 87 | :super 42 88 | :orbital-offset "acute" 89 | :orbital-offset-looks "wobbly"}))) 90 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit' and orbital_offset = 'acute' and orbital_offset_looks = 'wobbly'" 91 | (sub q {:orbital-offset-looks "wobbly" 92 | :super-position "quettabit" 93 | :orbital-offset "acute" 94 | :super 42 95 | :super-position-moons "up-and-down"}))) 96 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit' and orbital_offset = 'acute' and orbital_offset_looks = 'wobbly'" 97 | (sub q {:super 42 98 | :super-position "quettabit" 99 | :orbital-offset "acute" 100 | :super-position-moons "up-and-down" 101 | :orbital-offset-looks "wobbly"}))) 102 | (is (= "select * from planets where moons = 'up-and-down' and mass <= 42 and name = 'quettabit' and orbital_offset = 'acute' and orbital_offset_looks = 'wobbly'" 103 | (sub q {:super 42 104 | :orbital-offset "acute" 105 | :super-position "quettabit" 106 | :orbital-offset-looks "wobbly" 107 | :super-position-moons "up-and-down"})))))) 108 | 109 | (deftest should-sub-legacy-batch-upserts 110 | (testing "should correctly sub params for values in legacy batch upserts" 111 | (let [q "insert into planets (\"id\", \"system\", \"planet\") values :planets" 112 | sub (fn [q vs] (-> q (q/with-params {:planets {:as (q/seq->update-vals vs)}})))] 113 | (is (= (str "insert into planets (\"id\", \"system\", \"planet\") " 114 | "values " 115 | "('42','solar','earth')," 116 | "('34',null,'saturn')," 117 | "('28','','pluto')") 118 | (sub q [[42 "solar" "earth"] 119 | [34 nil "saturn"] 120 | [28 "" "pluto"]])))))) 121 | 122 | (deftest should-sub-batch-upserts 123 | (testing "should correctly sub params for values in batch upserts" 124 | (let [q "insert into planets (\"id\", \"system\", \"planet\") values :planets" 125 | sub (fn [q vs] (-> q (q/with-params {:planets {:as (q/seq->batch-params vs)}})))] 126 | (is (= (str "insert into planets (\"id\", \"system\", \"planet\") " 127 | "values " 128 | "(42,'solar','earth')," 129 | "('34',null,'saturn')," 130 | "(28,'','pluto')") 131 | (sub q [[42 "solar" "earth"] 132 | ["34" nil "saturn"] 133 | [28 "" "pluto"]])))))) 134 | 135 | (deftest should-handle-batch-upserts-with-complex-types 136 | (testing "should correctly handle batch upserts with complex types" 137 | (let [uuid1 (UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") 138 | uuid2 (UUID/fromString "bdd21ce1-24d5-4180-9307-7f560bb0e9e3") 139 | timestamp (Instant/parse "2023-01-15T12:34:56Z") 140 | q "INSERT INTO celestial_bodies (id, type, name, discovered_at) VALUES :bodies"] 141 | 142 | (is (= (str "INSERT INTO celestial_bodies (id, type, name, discovered_at) VALUES " 143 | "('f81d4fae-7dec-11d0-a765-00a0c91e6bf6','planet','earth','2023-01-15T12:34:56Z')," 144 | "('bdd21ce1-24d5-4180-9307-7f560bb0e9e3','planet','mars',null)") 145 | (q/with-params q {:bodies {:as (q/seq->batch-params [[uuid1 "planet" "earth" timestamp] 146 | [uuid2 "planet" "mars" nil]])}})))))) 147 | (deftest should-format-legacy-batch-values-correctly 148 | (testing "should format legacy batch values correctly with seq->update-vals" 149 | (are [input expected] (= expected (q/seq->update-vals input)) 150 | [[1 "earth" 5973.6]] "('1','earth','5973.6')" 151 | 152 | [[1 "earth" 5973.6] [2 "mars" 641.85]] "('1','earth','5973.6'),('2','mars','641.85')" 153 | 154 | [[1 nil "earth"] [2 "" "mars"] [3 "venus" 4868.5]] "('1',null,'earth'),('2','','mars'),('3','venus','4868.5')" 155 | 156 | [[(UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") "neptune"]] 157 | "('f81d4fae-7dec-11d0-a765-00a0c91e6bf6','neptune')"))) 158 | 159 | (deftest should-format-batch-values-correctly 160 | (testing "should format batch values correctly with seq->update-vals" 161 | (are [input expected] (= expected (q/seq->batch-params input)) 162 | [[1 "earth" 5973.6]] "(1,'earth',5973.6)" 163 | 164 | [[1 "earth" 5973.6] [2 "mars" 641.85]] "(1,'earth',5973.6),(2,'mars',641.85)" 165 | 166 | [[1 nil "earth"] [2 "" "mars"] [3 "venus" 4868.5]] "(1,null,'earth'),(2,'','mars'),(3,'venus',4868.5)" 167 | 168 | [[(UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") "neptune"]] 169 | "('f81d4fae-7dec-11d0-a765-00a0c91e6bf6','neptune')"))) 170 | (deftest should-properly-escape-basic-types 171 | (testing "should properly escape basic types" 172 | (are [input expected] (= expected (q/to-sql-string input)) 173 | nil "null" 174 | 42 "42" 175 | 42.5 "42.5" 176 | "earth" "'earth'" 177 | "mars'" "'mars'''" 178 | "" "''" 179 | true "true" 180 | false "false" 181 | :saturn "'saturn'" 182 | (UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") "'f81d4fae-7dec-11d0-a765-00a0c91e6bf6'" 183 | (Instant/parse "2023-01-15T12:34:56Z") "'2023-01-15T12:34:56Z'"))) 184 | 185 | (deftest should-handle-date-types 186 | (testing "should properly handle date types" 187 | (let [date (doto (Date.) (.setTime 1673789696000))] 188 | (is (re-find #"^'.*'$" (q/to-sql-string date)))))) 189 | 190 | (deftest should-format-collections-for-in-clauses 191 | (testing "should properly format collections for IN clauses" 192 | (are [input expected] (= expected (q/to-sql-string input)) 193 | [] "(null)" 194 | [1 2 3] "(1,2,3)" 195 | ["earth" "mars"] "('earth','mars')" 196 | ["earth" nil "mars"] "('earth',null,'mars')" 197 | ["earth" "" "mars"] "('earth','','mars')" 198 | [1 "mars" nil] "(1,'mars',null)" 199 | [:earth :mars] "('earth','mars')"))) 200 | 201 | (deftype SqlInjection [] 202 | Object 203 | (toString [_] "'; DROP TABLE planets; --")) 204 | 205 | (deftest should-reject-malicious-objects 206 | (testing "should reject malicious objects" 207 | 208 | (is (thrown? Exception (q/to-sql-string (SqlInjection.)))) 209 | (is (thrown? Exception (q/with-params "SELECT * FROM planets WHERE name = :name" 210 | {:name (SqlInjection.)}))))) 211 | 212 | (deftest should-substitute-various-parameter-types 213 | (testing "should properly substitute various parameter types in sql queries" 214 | (let [query "SELECT * FROM planets WHERE mass <= :mass AND name = :name AND active = :active AND id = :id AND created_at <= :time"] 215 | (are [params expected] (= expected (q/with-params query params)) 216 | {:mass 5973.6 217 | :name "earth" 218 | :active true 219 | :id (UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") 220 | :time (Instant/parse "2023-01-15T12:34:56Z")} 221 | "SELECT * FROM planets WHERE mass <= 5973.6 AND name = 'earth' AND active = true AND id = 'f81d4fae-7dec-11d0-a765-00a0c91e6bf6' AND created_at <= '2023-01-15T12:34:56Z'" 222 | 223 | {:mass nil 224 | :name "" 225 | :active false 226 | :id nil 227 | :time nil} 228 | "SELECT * FROM planets WHERE mass <= null AND name = '' AND active = false AND id = null AND created_at <= null")))) 229 | 230 | (deftest should-substitute-parameters-with-shared-prefixes-in-correct-order 231 | (testing "should substitute parameters with shared prefixes in correct order" 232 | (let [query "INSERT INTO galaxies VALUES (:galaxy-id, :galaxy, :galaxy-type, :galaxy-id-alt)"] 233 | (is (= "INSERT INTO galaxies VALUES ('milky-way-42', 'milky way', 'spiral', 'alt-42')" 234 | (q/with-params query {:galaxy "milky way" 235 | :galaxy-id "milky-way-42" 236 | :galaxy-type "spiral" 237 | :galaxy-id-alt "alt-42"})))))) 238 | 239 | (deftest should-handle-as-option-without-escaping 240 | (testing "should insert parameters with :as option without escaping" 241 | (let [query "SELECT * FROM planets WHERE mass IN :masses AND name LIKE :pattern"] 242 | (is (= "SELECT * FROM planets WHERE mass IN (1, 2, 3) AND name LIKE '%ar%'" 243 | (q/with-params query {:masses {:as "(1, 2, 3)"} 244 | :pattern {:as "'%ar%'"}}))) 245 | 246 | (is (= "SELECT * FROM planets WHERE mass IN NULL AND name LIKE NULL" 247 | (q/with-params query {:masses {:as "NULL"} 248 | :pattern {:as "NULL"}})))))) 249 | 250 | (deftest should-prevent-sql-injection-attempts 251 | (testing "should properly escape sql injection attempts" 252 | (let [query "SELECT * FROM planets WHERE name = :name"] 253 | (is (= "SELECT * FROM planets WHERE name = 'earth'' OR ''1''=''1'" 254 | (q/with-params query {:name "earth' OR '1'='1"}))) 255 | 256 | (is (= "SELECT * FROM planets WHERE name = 'mars''; DROP TABLE planets; --'" 257 | (q/with-params query {:name "mars'; DROP TABLE planets; --"})))))) 258 | 259 | (deftest should-throw-exception-for-empty-query 260 | (testing "should throw an exception for empty query" 261 | (is (thrown? Exception (q/with-params "" {:name "mars"}))))) 262 | 263 | (deftest should-substitute-various-parameter-types 264 | (testing "should properly substitute various parameter types in sql queries" 265 | (let [query "SELECT * FROM planets WHERE mass <= :mass AND name = :name AND active = :active AND id = :id AND created_at <= :time"] 266 | (are [params expected] (= expected (q/with-params query params)) 267 | {:mass 5973.6 268 | :name "earth" 269 | :active true 270 | :id (UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") 271 | :time (Instant/parse "2023-01-15T12:34:56Z")} 272 | "SELECT * FROM planets WHERE mass <= 5973.6 AND name = 'earth' AND active = true AND id = 'f81d4fae-7dec-11d0-a765-00a0c91e6bf6' AND created_at <= '2023-01-15T12:34:56Z'" 273 | 274 | {:mass nil 275 | :name "" 276 | :active false 277 | :id nil 278 | :time nil} 279 | "SELECT * FROM planets WHERE mass <= null AND name = '' AND active = false AND id = null AND created_at <= null")))) 280 | 281 | (deftest should-build-queries-with-collection-parameters 282 | (testing "should properly build queries with collection parameters" 283 | (let [query "SELECT * FROM planets WHERE id IN :ids AND name IN :names"] 284 | (are [params expected] (= expected (q/with-params query params)) 285 | {:ids [1 2 3] 286 | :names ["earth" "mars" "venus"]} 287 | "SELECT * FROM planets WHERE id IN (1,2,3) AND name IN ('earth','mars','venus')" 288 | 289 | {:ids [] 290 | :names []} 291 | "SELECT * FROM planets WHERE id IN (null) AND name IN (null)" 292 | 293 | {:ids [1 2 nil 3] 294 | :names ["earth" nil "mars"]} 295 | "SELECT * FROM planets WHERE id IN (1,2,null,3) AND name IN ('earth',null,'mars')")))) 296 | 297 | (deftest should-build-queries-with-mixed-collection-types 298 | (testing "should properly build queries with mixed collection types" 299 | (let [query "SELECT * FROM celestial_objects WHERE type IN :types AND id IN :ids AND discovery_date IN :dates"] 300 | (is (= (str "SELECT * FROM celestial_objects WHERE type IN ('planet','moon','asteroid') AND " 301 | "id IN ('f81d4fae-7dec-11d0-a765-00a0c91e6bf6','bdd21ce1-24d5-4180-9307-7f560bb0e9e3') AND " 302 | "discovery_date IN ('2023-01-15T12:34:56Z','2023-02-20T15:30:00Z','2023-03-31')") 303 | (q/with-params query 304 | {:types ["planet" "moon" "asteroid"] 305 | :ids [(UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") 306 | (UUID/fromString "bdd21ce1-24d5-4180-9307-7f560bb0e9e3")] 307 | :dates [(Instant/parse "2023-01-15T12:34:56Z") 308 | (Instant/parse "2023-02-20T15:30:00Z") 309 | (LocalDate/parse "2023-03-31")]})))))) 310 | 311 | (deftest should-build-complex-queries-with-mixed-parameters 312 | (testing "should build complex queries with mixed scalar and collection parameters" 313 | (let [query (str "SELECT * FROM observations " 314 | "WHERE planet_id = :planet_id " 315 | "AND observer_id IN :observer_ids " 316 | "AND observation_type IN :types " 317 | "AND observation_date BETWEEN :start_date AND :end_date " 318 | "AND confidence > :min_confidence")] 319 | (is (= (str "SELECT * FROM observations " 320 | "WHERE planet_id = 'f81d4fae-7dec-11d0-a765-00a0c91e6bf6' " 321 | "AND observer_id IN (1,2,3) " 322 | "AND observation_type IN ('visual','spectral','radio') " 323 | "AND observation_date BETWEEN '2023-01-15T12:34:56Z' AND '2023-02-20T15:30:00Z' " 324 | "AND confidence > 0.75") 325 | (q/with-params query 326 | {:planet_id (UUID/fromString "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") 327 | :observer_ids [1 2 3] 328 | :types ["visual" "spectral" "radio"] 329 | :start_date (Instant/parse "2023-01-15T12:34:56Z") 330 | :end_date (Instant/parse "2023-02-20T15:30:00Z") 331 | :min_confidence 0.75})))))) 332 | --------------------------------------------------------------------------------