├── test └── yesql │ ├── sample_files │ ├── syntax_error.sql │ ├── tagged_no_comments.sql │ ├── quoting.sql │ ├── tagged_no_name.sql │ ├── current_time.sql │ ├── acceptance_test_single.sql │ ├── tagged_two_names.sql │ ├── parser_edge_cases.sql │ ├── complicated_docstring.sql │ ├── inline_comments.sql │ ├── mixed_parameters.sql │ ├── combined_file.sql │ └── acceptance_test_combined.sql │ ├── middleware_test.clj │ ├── util_test.clj │ ├── statement_parser_test.clj │ ├── acceptance_test.clj │ ├── acceptance_test_middleware.clj │ ├── generate_test.clj │ ├── core_test.clj │ └── queryfile_parser_test.clj ├── .travis.yml ├── src └── yesql │ ├── types.clj │ ├── statement.bnf │ ├── instaparse_util.clj │ ├── queryfile.bnf │ ├── util.clj │ ├── statement_parser.clj │ ├── queryfile_parser.clj │ ├── core.clj │ └── generate.clj ├── .gitignore ├── project.clj ├── LICENSE.txt └── README.md /test/yesql/sample_files/syntax_error.sql: -------------------------------------------------------------------------------- 1 | SELOCT * 2 | FROM user; 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: clojure 3 | lein: lein2 4 | script: lein2 test-all 5 | -------------------------------------------------------------------------------- /src/yesql/types.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.types) 2 | 3 | (defrecord Query 4 | [name info docstring statement]) 5 | -------------------------------------------------------------------------------- /test/yesql/sample_files/tagged_no_comments.sql: -------------------------------------------------------------------------------- 1 | -- name: the-time 2 | SELECT CURRENT_TIMESTAMP 3 | FROM SYSIBM.SYSDUMMY1 4 | -------------------------------------------------------------------------------- /test/yesql/sample_files/quoting.sql: -------------------------------------------------------------------------------- 1 | -- SQL's quoting rules. 2 | SELECT ''''||'can''t'||'''' AS word 3 | FROM SYSIBM.SYSDUMMY1 4 | -------------------------------------------------------------------------------- /test/yesql/sample_files/tagged_no_name.sql: -------------------------------------------------------------------------------- 1 | -- Error - This query has no name. 2 | SELECT CURRENT_TIMESTAMP FROM SYSIBM.SYSDUMMY1 3 | -------------------------------------------------------------------------------- /test/yesql/sample_files/current_time.sql: -------------------------------------------------------------------------------- 1 | -- Just selects the current time. 2 | -- Nothing fancy. 3 | SELECT CURRENT_TIMESTAMP AS time 4 | FROM SYSIBM.SYSDUMMY1 5 | -------------------------------------------------------------------------------- /test/yesql/sample_files/acceptance_test_single.sql: -------------------------------------------------------------------------------- 1 | -- Just selects the current time. 2 | -- Nothing fancy. 3 | SELECT CURRENT_TIMESTAMP AS time 4 | FROM SYSIBM.SYSDUMMY1 5 | -------------------------------------------------------------------------------- /test/yesql/sample_files/tagged_two_names.sql: -------------------------------------------------------------------------------- 1 | -- name: the-time 2 | -- Error - This query has two names. 3 | -- name: the-time2 4 | SELECT CURRENT_TIMESTAMP FROM SYSIBM.SYSDUMMY1 5 | -------------------------------------------------------------------------------- /test/yesql/sample_files/parser_edge_cases.sql: -------------------------------------------------------------------------------- 1 | -- name: this-has-trailing-whitespace 2 | -- The name has trailing whitespace here ^^^^ 3 | -- But the parser should not blow up. 4 | SELECT CURRENT_TIMESTAMP 5 | FROM SYSIBM.SYSDUMMY1 6 | -------------------------------------------------------------------------------- /test/yesql/sample_files/complicated_docstring.sql: -------------------------------------------------------------------------------- 1 | -- This is a simple query. 2 | -- 3 | -- but... 4 | -- 5 | -- The docstring 6 | -- is tricksy. 7 | SELECT CURRENT_TIMESTAMP AS time 8 | FROM SYSIBM.SYSDUMMY1 9 | -- Isn't it? 10 | -------------------------------------------------------------------------------- /test/yesql/sample_files/inline_comments.sql: -------------------------------------------------------------------------------- 1 | -- It's the time query again, but there's an inline comment to make the parser fret. 2 | SELECT 3 | CURRENT_TIMESTAMP AS time, -- Here is an inline comment. 4 | 'Not -- a comment' AS string 5 | FROM SYSIBM.SYSDUMMY1 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /lib 3 | /classes 4 | /checkouts 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | .lein-deps-sum 10 | .lein-failures 11 | .lein-plugins 12 | .lein-repl-history 13 | 14 | /derby.log 15 | /README.html 16 | /README.pdf 17 | /README.tex 18 | /.nrepl-port 19 | -------------------------------------------------------------------------------- /test/yesql/sample_files/mixed_parameters.sql: -------------------------------------------------------------------------------- 1 | -- Here's a query with some named and some anonymous parameters. 2 | -- (...and some repeats.) 3 | SELECT CURRENT_TIMESTAMP AS time 4 | FROM SYSIBM.SYSDUMMY1 5 | WHERE :value1 = 1 6 | AND :value2 = 2 7 | AND ? = 3 8 | AND :value2 = 2 9 | AND ? = 4 10 | -------------------------------------------------------------------------------- /test/yesql/middleware_test.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.middleware-test) 2 | 3 | (def log-query-middleware 4 | (fn [ query-fn ] 5 | (fn [args call-options] 6 | (let [ query-name (get-in call-options [:query :name]) ] 7 | (println [ :begin query-name ]) 8 | (let [ result (query-fn args call-options) ] 9 | (println [ :end query-name]) 10 | result))))) 11 | 12 | -------------------------------------------------------------------------------- /src/yesql/statement.bnf: -------------------------------------------------------------------------------- 1 | statement = substatement (parameter substatement)* 2 | 3 | substatement = (( #"[^?:']+" | "::") | string)* 4 | 5 | string = string-delimiter string-normal* (string-special string-normal*)* string-delimiter 6 | string-delimiter = "'" 7 | string-normal = #"[^'\\]*" 8 | string-special = "\\" #"." 9 | 10 | parameter = placeholder-parameter | named-parameter 11 | 12 | placeholder-parameter = "?" 13 | 14 | named-parameter = <":"> #"[^\s,\"':&;()|=+\-*%/\\<>^\[\]]+" 15 | 16 | whitespace = (' ' | '\t')+ 17 | -------------------------------------------------------------------------------- /test/yesql/sample_files/combined_file.sql: -------------------------------------------------------------------------------- 1 | 2 | 3 | -- name: the-time 4 | -- This is another time query. 5 | -- Exciting, huh? 6 | SELECT CURRENT_TIMESTAMP 7 | FROM SYSIBM.SYSDUMMY1 8 | 9 | -- name: sums 10 | -- Just in case you've forgotten 11 | -- I made you a sum. 12 | SELECT 13 | :a + 1 adder, 14 | :b - 1 subtractor 15 | FROM SYSIBM.SYSDUMMY1 16 | 17 | -- name: edge 18 | -- And here's an edge case. 19 | -- Comments in the middle of the query. 20 | SELECT 21 | 1 + 1 AS two 22 | -- I find this query dull. 23 | FROM SYSIBM.SYSDUMMY1 24 | -------------------------------------------------------------------------------- /src/yesql/instaparse_util.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.instaparse-util 2 | (:require [instaparse.core :as instaparse]) 3 | (:import [java.io StringWriter])) 4 | 5 | (defn process-instaparse-result 6 | [parse-results context] 7 | (if-let [failure (instaparse/get-failure parse-results)] 8 | (binding [*out* (StringWriter.)] 9 | (instaparse.failure/pprint-failure failure) 10 | (throw (ex-info (.toString *out*) 11 | failure))) 12 | (if (second parse-results) 13 | (throw (ex-info "Ambiguous parse - please report this as a bug at https://github.com/krisajenkins/yesql/issues" 14 | {:variations (count parse-results)})) 15 | (first parse-results)))) 16 | -------------------------------------------------------------------------------- /test/yesql/sample_files/acceptance_test_combined.sql: -------------------------------------------------------------------------------- 1 | -- name: create-person-table! 2 | CREATE TABLE person ( 3 | person_id INTEGER NOT NULL GENERATED ALWAYS AS IDENTITY, 4 | name VARCHAR(20) UNIQUE NOT NULL, 5 | age INTEGER NOT NULL 6 | ) 7 | 8 | -- name: insert-person :age 21 | 22 | -- name: find-by-age 23 | SELECT * 24 | FROM person 25 | WHERE age IN (:age) 26 | 27 | -- name: update-age! 28 | UPDATE person 29 | SET age = :age 30 | WHERE name = :name 31 | 32 | -- name: delete-person! 33 | DELETE FROM person 34 | WHERE name = :name 35 | 36 | -- name: drop-person-table! 37 | DROP TABLE person 38 | -------------------------------------------------------------------------------- /test/yesql/util_test.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.util-test 2 | (:require [expectations :refer :all] 3 | [clojure.template :refer [do-template]] 4 | [yesql.util :refer :all])) 5 | 6 | ;;; Test underscores-to-dashes 7 | (do-template [input output] 8 | (expect output 9 | (underscores-to-dashes input)) 10 | 11 | "nochange" "nochange" 12 | "current_time" "current-time" 13 | "this_is_it" "this-is-it") 14 | 15 | ;;; Test slurp-from-classpath 16 | (expect #"\bSELECT\b" 17 | (slurp-from-classpath "yesql/sample_files/current_time.sql")) 18 | 19 | (expect java.io.FileNotFoundException 20 | (slurp-from-classpath "nothing/here")) 21 | 22 | ;;; Test str-non-nil 23 | (expect "" 24 | (str-non-nil)) 25 | 26 | (expect "1" 27 | (str-non-nil 1)) 28 | 29 | (expect ":a2cd" 30 | (str-non-nil :a 2 'c "d")) 31 | -------------------------------------------------------------------------------- /src/yesql/queryfile.bnf: -------------------------------------------------------------------------------- 1 | queries = query* 2 | query = name info* docstring? statement 3 | 4 | docstring = comment+ 5 | 6 | statement = line (line | )* 7 | 8 | name = non-whitespace 9 | info = symbol <":"> rest-of-line 10 | 11 | comment = !PARAM_TAG (non-whitespace whitespace?)* newline 12 | line = whitespace? !COMMENT_MARKER (non-whitespace whitespace?)* newline 13 | 14 | COMMENT_MARKER = '--' 15 | 16 | PARAM_TAG = NAME_TAG | INFO_TAG 17 | 18 | NAME_TAG = "name" 19 | INFO_TAG = "info-" 20 | 21 | blank-line = whitespace* newline 22 | any = (whitespace | non-whitespace)+ 23 | newline = '\n' | '\r\n' 24 | whitespace = (' ' | '\t')+ 25 | non-whitespace = #'\S+' 26 | rest-of-line = #'[^\n]*' 27 | symbol = #'([a-zA-Z0-9-]+)' -------------------------------------------------------------------------------- /src/yesql/util.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.util 2 | (:require [clojure.java.io :as io] 3 | [clojure.string :as string] 4 | [clojure.pprint :refer [pprint]]) 5 | (:import [java.io FileNotFoundException])) 6 | 7 | (defn underscores-to-dashes 8 | [string] 9 | (when string 10 | (string/replace string "_" "-"))) 11 | 12 | (defn str-non-nil 13 | "Exactly like `clojure.core/str`, except it returns an empty string 14 | with no args (whereas `str` would return `nil`)." 15 | [& args] 16 | (apply str "" args)) 17 | 18 | (defn slurp-from-classpath 19 | "Slurps a file from the classpath." 20 | [path] 21 | (or (some-> path 22 | io/resource 23 | slurp) 24 | (throw (FileNotFoundException. path)))) 25 | 26 | ;;; TODO There may well be a built-in for this. If there is, I have not found it. 27 | (defn create-root-var 28 | "Given a name and a value, intern a var in the current namespace, taking metadata from the value." 29 | ([name value] 30 | (create-root-var *ns* name value)) 31 | 32 | ([ns name value] 33 | (intern ns 34 | (with-meta (symbol name) 35 | (meta value)) 36 | value))) 37 | -------------------------------------------------------------------------------- /src/yesql/statement_parser.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.statement-parser 2 | (:require [clojure.java.io :as io] 3 | [clojure.string :refer [join]] 4 | [instaparse.core :as instaparse] 5 | [yesql.util :refer [str-non-nil]] 6 | [yesql.instaparse-util :refer [process-instaparse-result]]) 7 | (:import [yesql.types Query])) 8 | 9 | (def parser 10 | (instaparse/parser (io/resource "yesql/statement.bnf"))) 11 | 12 | (def ^:private parser-transforms 13 | {:statement vector 14 | :substatement str-non-nil 15 | :string str-non-nil 16 | :string-special str-non-nil 17 | :string-delimiter identity 18 | :string-normal identity 19 | :parameter identity 20 | :placeholder-parameter symbol 21 | :named-parameter symbol}) 22 | 23 | (defn- parse-statement 24 | [statement context] 25 | (process-instaparse-result 26 | (instaparse/transform parser-transforms 27 | (instaparse/parses parser statement :start :statement)) 28 | context)) 29 | 30 | (defmulti tokenize 31 | "Turn a raw SQL statement into a vector of SQL-substrings 32 | interspersed with clojure symbols for the query's parameters. 33 | 34 | For example, `(parse-statement \"SELECT * FROM person WHERE :age > age\")` 35 | becomes: `[\"SELECT * FROM person WHERE \" age \" > age\"]`" 36 | (fn [this] (type this))) 37 | 38 | (defmethod tokenize String 39 | [this] 40 | (parse-statement this nil)) 41 | 42 | (defmethod tokenize Query 43 | [{:keys [statement]}] 44 | (parse-statement statement nil)) 45 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject yesql "0.6.0-alpha2-SNAPSHOT" 2 | :description "A Clojure library for using SQL" 3 | :url "https://github.com/krisajenkins/yesql" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :dependencies [[org.clojure/clojure "1.12.0"] 7 | [org.clojure/java.jdbc "0.7.12" :exclusions [org.clojure/clojure]] 8 | [instaparse "1.5.0" :exclusions [org.clojure/clojure]]] 9 | :pedantic? :abort 10 | :scm {:name "git" 11 | :url "https://github.com/krisajenkins/yesql"} 12 | :deploy-repositories [["releases" {:url "https://repo.clojars.org"}]] 13 | :profiles {:dev {:dependencies [[expectations "2.1.10" :exclusions [org.clojure/clojure]] 14 | [org.apache.derby/derby "10.16.1.1"]] 15 | :plugins [[lein-autoexpect "1.4.0"] 16 | [lein-expectations "0.0.8" :exclusions [org.clojure/clojure]]]} 17 | :1.7 {:dependencies [[org.clojure/clojure "1.7.0"]]} 18 | :1.8 {:dependencies [[org.clojure/clojure "1.8.0"]]} 19 | :1.9 {:dependencies [[org.clojure/clojure "1.9.0"]]} 20 | :1.10 {:dependencies [[org.clojure/clojure "1.10.3"]]} 21 | :1.11 {:dependencies [[org.clojure/clojure "1.11.4"]]} 22 | :1.12 {:dependencies [[org.clojure/clojure "1.12.0"]]}} 23 | :aliases {"test-all" ["with-profile" "+1.7:+1.8:+1.9:+1.10:+1.11:+1.12" "do" 24 | ["clean"] 25 | ["expectations"]] 26 | "test-ancient" ["expectations"]}) 27 | -------------------------------------------------------------------------------- /src/yesql/queryfile_parser.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.queryfile-parser 2 | (:require [clojure.java.io :as io] 3 | [clojure.edn :as edn] 4 | [clojure.string :as str :refer [join trim]] 5 | [instaparse.core :as instaparse] 6 | [yesql.types :refer [map->Query]] 7 | [yesql.util :refer [str-non-nil]] 8 | [yesql.instaparse-util :refer [process-instaparse-result]])) 9 | 10 | (def parser 11 | (let [url (io/resource "yesql/queryfile.bnf")] 12 | (assert url) 13 | (instaparse/parser url))) 14 | 15 | (defn- rm-semicolon [s] 16 | (str/replace s #";$" "")) 17 | 18 | (defn- separate [pred s] 19 | ((juxt filter remove) pred s)) 20 | 21 | (def parser-transforms 22 | {:whitespace str-non-nil 23 | :non-whitespace str-non-nil 24 | :newline str-non-nil 25 | :any str-non-nil 26 | :line str-non-nil 27 | :rest-of-line (fn [ & args ] (apply str-non-nil args)) 28 | :info (fn [[_ key] & args ] 29 | [:info (keyword key) (edn/read-string (apply str-non-nil args))]) 30 | :comment (fn [& args] 31 | [:comment (apply str-non-nil args)]) 32 | :docstring (fn [& comments] 33 | [:docstring (trim (join (map second comments)))]) 34 | :statement (fn [& lines] 35 | [:statement (rm-semicolon (trim (join lines)))]) 36 | :query (fn [& args] 37 | (let [[info-args query-args] (separate #(= (first %) :info) args) 38 | infos (reduce (fn [infos [_ k v]] 39 | (assoc infos k v)) {} info-args)] 40 | (map->Query (assoc (into {} query-args) 41 | :info infos)))) 42 | :queries list}) 43 | 44 | (defn parse-tagged-queries 45 | "Parses a string with Yesql's defqueries syntax into a sequence of maps." 46 | [text] 47 | (process-instaparse-result 48 | (instaparse/transform parser-transforms 49 | (instaparse/parses parser 50 | (str text "\n") ;;; TODO This is a workaround for files with no end-of-line marker. 51 | :start :queries)) 52 | {})) 53 | -------------------------------------------------------------------------------- /test/yesql/statement_parser_test.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.statement-parser-test 2 | (:require [expectations :refer :all] 3 | [clojure.template :refer [do-template]] 4 | [yesql.types :refer [map->Query]] 5 | [yesql.statement-parser :refer :all])) 6 | 7 | (do-template [statement _ split-result] 8 | (do (expect (quote split-result) 9 | (tokenize statement)) 10 | (expect (quote split-result) 11 | (tokenize (map->Query {:name "test" 12 | :doctstring "A test case." 13 | :statement statement})))) 14 | 15 | ;; Simple tests 16 | "SELECT 1 FROM dual" => ["SELECT 1 FROM dual"] 17 | "SELECT ? FROM dual" => ["SELECT " ? " FROM dual"] 18 | 19 | "SELECT :value FROM dual" => ["SELECT " value " FROM dual"] 20 | "SELECT 'test'\nFROM dual" => ["SELECT 'test'\nFROM dual"] 21 | "SELECT :value, :other_value FROM dual" => ["SELECT " value ", " other_value " FROM dual"] 22 | 23 | ;; Tokenization rules 24 | "SELECT :age-5 FROM dual" 25 | => ["SELECT " age "-5 FROM dual"] 26 | 27 | ;; Mixing named & placeholder parameters 28 | "SELECT :value, ? FROM dual" 29 | => ["SELECT " value ", " ? " FROM dual"] 30 | 31 | ;; Escapes 32 | "SELECT :value, :other_value, ':not_a_value' FROM dual" 33 | => ["SELECT " value ", " other_value ", ':not_a_value' FROM dual"] 34 | 35 | "SELECT 'not \\' :a_value' FROM dual" 36 | => ["SELECT 'not \\' :a_value' FROM dual"] 37 | 38 | ;; Casting 39 | "SELECT :value, :other_value, 5::text FROM dual" 40 | => ["SELECT " value ", " other_value ", 5::text FROM dual"] 41 | 42 | ;; Newlines are preserved. 43 | "SELECT :value, :other_value, 5::text\nFROM dual" 44 | => ["SELECT " value ", " other_value ", 5::text\nFROM dual"] 45 | 46 | ;; Complex 47 | "SELECT :a+2*:b+age::int FROM users WHERE username = ? AND :b > 0" 48 | => ["SELECT " a "+2*" b "+age::int FROM users WHERE username = " ? " AND " b " > 0"] 49 | 50 | "SELECT :value1 + ? + value2 + ? + :value1\nFROM SYSIBM.SYSDUMMY1" 51 | => ["SELECT " value1 " + " ? " + value2 + " ? " + " value1 "\nFROM SYSIBM.SYSDUMMY1"] 52 | 53 | "SELECT ARRAY [:value1] FROM dual" 54 | => ["SELECT ARRAY [" value1 "] FROM dual"]) 55 | -------------------------------------------------------------------------------- /src/yesql/core.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.core 2 | (:require [yesql.util :refer [slurp-from-classpath]] 3 | [yesql.generate :refer [generate-var]] 4 | [yesql.queryfile-parser :refer [parse-tagged-queries]])) 5 | 6 | (defn defqueries 7 | "Defines several query functions, as defined in the given SQL file. 8 | Each query in the file must begin with a `-- name: ` marker, 9 | followed by optional comment lines (which form the docstring), followed by 10 | the query itself." 11 | ([filename] 12 | (defqueries filename {})) 13 | ([filename options] 14 | (doall (->> filename 15 | slurp-from-classpath 16 | parse-tagged-queries 17 | (map #(generate-var % options)))))) 18 | 19 | (defn defquery* 20 | [name filename options] 21 | ;;; TODO Now that we have a better parser, this is a somewhat suspicious way of writing this code. 22 | (doall (->> filename 23 | slurp-from-classpath 24 | (format "-- name: %s\n%s" name) 25 | parse-tagged-queries 26 | (map #(generate-var % options))))) 27 | 28 | ;;; defquery is a macro solely because of the unquoted symbol it accepts 29 | ;;; as its first argument. It is tempting to deprecate defquery. There 30 | ;;; again, it makes things so easy to get started with yesql it might 31 | ;;; be worth keeping for that reason alone. 32 | (defmacro defquery 33 | "Defines a query function, as defined in the given SQL file. 34 | Any comments in that file will form the docstring." 35 | ([name filename] 36 | `(defquery ~name ~filename {})) 37 | ([name filename options] 38 | `(defquery* ~(str name) ~filename ~options))) 39 | 40 | (defmacro require-sql 41 | "Require-like behavior for yesql, to prevent namespace pollution. 42 | Parameter is a list of [sql-source-file-name [:as alias] [:refer [var1 var2]]] 43 | At least one of :as or :refer is required 44 | Usage: (require-sql [\"sql/foo.sql\" :as foo-sql :refer [some-query-fn])" 45 | [[sql-file & {:keys [as refer]} :as require-args]] 46 | (when-not (or as refer) 47 | (throw (Exception. "Missing an :as or a :refer"))) 48 | (let [current-ns (ns-name *ns*) 49 | ;; Keep this .sql file's defqueries in a predictable place: 50 | target-ns (symbol (str "yesquire/" sql-file))] 51 | `(do 52 | (ns-unalias *ns* '~as) 53 | (create-ns '~target-ns) 54 | (in-ns '~target-ns) 55 | (clojure.core/require '[yesql.core]) 56 | (yesql.core/defqueries ~sql-file) 57 | (clojure.core/in-ns '~current-ns) 58 | ~(when as 59 | `(clojure.core/alias '~as '~target-ns)) 60 | ~(when refer 61 | `(clojure.core/refer '~target-ns :only '~refer))))) 62 | -------------------------------------------------------------------------------- /test/yesql/acceptance_test.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.acceptance-test 2 | (:require [expectations :refer :all] 3 | [clojure.java.jdbc :as jdbc] 4 | [yesql.core :refer :all]) 5 | (:import [java.sql SQLException SQLSyntaxErrorException SQLDataException])) 6 | 7 | (def derby-db {:subprotocol "derby" 8 | :subname (gensym "memory:") 9 | :create true}) 10 | 11 | ;;; Single query. 12 | (defquery current-time 13 | "yesql/sample_files/acceptance_test_single.sql") 14 | 15 | (expect java.util.Date 16 | (-> (current-time {} {:connection derby-db}) 17 | first 18 | :time)) 19 | 20 | ;;; Multiple-query workflow. 21 | (defqueries 22 | "yesql/sample_files/acceptance_test_combined.sql" 23 | {:connection derby-db}) 24 | 25 | ;; Create 26 | (expect (create-person-table!)) 27 | 28 | ;; Insert -> Select. 29 | (expect {:1 1M} (insert-person Select. 44 | (expect 1 (update-age! {:age 38 45 | :name "Alice"})) 46 | (expect 0 (update-age! {:age 38 47 | :name "David"})) 48 | 49 | (expect 3 (count (find-older-than {:age 10}))) 50 | (expect 2 (count (find-older-than {:age 30}))) 51 | (expect 0 (count (find-older-than {:age 50}))) 52 | 53 | ;; Delete -> Select. 54 | (expect 1 (delete-person! {:name "Alice"})) 55 | 56 | (expect 2 (count (find-older-than {:age 10}))) 57 | (expect 1 (count (find-older-than {:age 30}))) 58 | (expect 0 (count (find-older-than {:age 50}))) 59 | 60 | ;; Failing transaction: Insert with abort. 61 | ;; Insert two rows in a transaction. The second throws a deliberate error, meaning no new rows created. 62 | (expect 2 (count (find-older-than {:age 10}))) 63 | 64 | (expect SQLException 65 | (jdbc/with-db-transaction [connection derby-db] 66 | (insert-person (current-time {} {:connection derby-db}) 21 | first 22 | :time)) 23 | 24 | ;;; Multiple-query workflow. 25 | (defqueries 26 | "yesql/sample_files/acceptance_test_combined.sql" 27 | {:middleware (middleware/set-connection derby-db)}) 28 | 29 | ;; Create 30 | (expect (create-person-table!)) 31 | 32 | ;; Insert -> Select. 33 | (expect {:1 1M} (insert-person Select. 48 | (expect 1 (update-age! {:age 38 49 | :name "Alice"})) 50 | (expect 0 (update-age! {:age 38 51 | :name "David"})) 52 | 53 | (expect 3 (count (find-older-than {:age 10}))) 54 | (expect 2 (count (find-older-than {:age 30}))) 55 | (expect 0 (count (find-older-than {:age 50}))) 56 | 57 | ;; Delete -> Select. 58 | (expect 1 (delete-person! {:name "Alice"})) 59 | 60 | (expect 2 (count (find-older-than {:age 10}))) 61 | (expect 1 (count (find-older-than {:age 30}))) 62 | (expect 0 (count (find-older-than {:age 50}))) 63 | 64 | ;; Failing transaction: Insert with abort. 65 | ;; Insert two rows in a transaction. The second throws a deliberate error, meaning no new rows created. 66 | (expect 2 (count (find-older-than {:age 10}))) 67 | 68 | (expect SQLException 69 | (jdbc/with-db-transaction [connection derby-db] 70 | (insert-person #{} 13 | 14 | "SELECT * FROM user WHERE user_id = ?" 15 | => #{:?} 16 | 17 | "SELECT * FROM user WHERE user_id = :name" 18 | => #{:name} 19 | 20 | "SELECT * FROM user WHERE user_id = :name AND country = :country AND age IN (?,?)" 21 | => #{:name :country :?}) 22 | 23 | ;;; Testing in-list-parmaeter for "IN-list" statements. 24 | (expect [true true true false false] 25 | (map in-list-parameter? 26 | (list [] 27 | (list) 28 | (lazy-seq (cons 1 [2])) 29 | {:a 1} 30 | #{1 2 3}))) 31 | 32 | ;;; Testing reassemble-query 33 | (do-template [statement parameters _ rewritten-form] 34 | (expect rewritten-form 35 | (rewrite-query-for-jdbc (tokenize statement) 36 | parameters)) 37 | 38 | "SELECT age FROM users WHERE country = :country" 39 | {:country "gb"} 40 | => ["SELECT age FROM users WHERE country = ?" "gb"] 41 | 42 | "SELECT age FROM users WHERE (country = ? OR country = ?) AND name = :name" 43 | {:? ["gb" "us"] 44 | :name "tom"} 45 | => ["SELECT age FROM users WHERE (country = ? OR country = ?) AND name = ?" "gb" "us" "tom"] 46 | 47 | ;;; Vectors trigger IN expansion 48 | "SELECT age FROM users WHERE country = :country AND name IN (:names)" 49 | {:country "gb" 50 | :names ["tom" "dick" "harry"]} 51 | => ["SELECT age FROM users WHERE country = ? AND name IN (?,?,?)" "gb" "tom" "dick" "harry"] 52 | 53 | ;;; Lists trigger IN expansion 54 | "SELECT age FROM users WHERE country = :country AND name IN (:names)" 55 | {:country "gb" 56 | :names (list "tom" "dick" "harry")} 57 | => ["SELECT age FROM users WHERE country = ? AND name IN (?,?,?)" "gb" "tom" "dick" "harry"] 58 | 59 | ;;; Lazy seqs of cons of vectors trigger IN expansion 60 | "SELECT age FROM users WHERE country = :country AND name IN (:names)" 61 | {:country "gb" 62 | :names (lazy-seq (cons "tom" ["dick" "harry"]))} 63 | => ["SELECT age FROM users WHERE country = ? AND name IN (?,?,?)" "gb" "tom" "dick" "harry"] 64 | 65 | ;;; Maps do not trigger IN expansion 66 | "INSERT INTO json (source, data) VALUES (:source, :data)" 67 | {:source "google" 68 | :data {:a 1}} 69 | => ["INSERT INTO json (source, data) VALUES (?, ?)" "google" {:a 1}] 70 | 71 | "INSERT INTO json (data, source) VALUES (:data, :source)" 72 | {:source "google" 73 | :data {:a 1}} 74 | => ["INSERT INTO json (data, source) VALUES (?, ?)" {:a 1} "google"] 75 | 76 | ;;; Empty IN-lists are allowed by Yesql - though most DBs will complain. 77 | "SELECT age FROM users WHERE country = :country AND name IN (:names)" 78 | {:country "gb" 79 | :names []} 80 | => ["SELECT age FROM users WHERE country = ? AND name IN ()" "gb"] 81 | 82 | "SELECT * FROM users WHERE group_ids IN(:group_ids) AND parent_id = :parent_id" 83 | {:group_ids [1 2] 84 | :parent_id 3} 85 | => ["SELECT * FROM users WHERE group_ids IN(?,?) AND parent_id = ?" 1 2 3]) 86 | 87 | ;;; Incorrect parameters. 88 | (expect AssertionError 89 | (rewrite-query-for-jdbc (tokenize "SELECT age FROM users WHERE country = :country AND name = :name") 90 | {:country "gb"})) 91 | 92 | (expect AssertionError 93 | (rewrite-query-for-jdbc (tokenize "SELECT age FROM users WHERE country = ? AND name = ?") 94 | {})) 95 | -------------------------------------------------------------------------------- /test/yesql/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.core-test 2 | (:require [clojure.java.jdbc :as jdbc] 3 | [clojure.string :refer [upper-case]] 4 | [expectations :refer :all] 5 | [yesql.core :refer :all] 6 | [yesql.middleware-test :refer :all] 7 | [yesql.middleware :as middleware])) 8 | 9 | (def derby-db {:subprotocol "derby" 10 | :subname (gensym "memory:") 11 | :create true}) 12 | 13 | ;;; Test-environment check. Can we actually access the test DB? 14 | (expect (more-> java.sql.Timestamp (-> first :1)) 15 | (jdbc/query derby-db 16 | ["SELECT CURRENT_TIMESTAMP FROM SYSIBM.SYSDUMMY1"])) 17 | 18 | (defquery current-time-query 19 | "yesql/sample_files/current_time.sql" 20 | {:connection derby-db}) 21 | 22 | (defquery current-time-query-middleware 23 | "yesql/sample_files/current_time.sql" 24 | {:middleware (middleware/set-connection derby-db)}) 25 | 26 | (defquery current-time-query-middleware-fnarg 27 | "yesql/sample_files/current_time.sql" 28 | {:middleware (middleware/set-connection (constantly derby-db))}) 29 | 30 | (defquery mixed-parameters-query 31 | "yesql/sample_files/mixed_parameters.sql" 32 | {:connection derby-db}) 33 | 34 | ;;; Test querying. 35 | (expect (more-> java.util.Date 36 | (-> first :time)) 37 | (current-time-query)) 38 | 39 | (expect (more-> java.util.Date 40 | (-> first :time)) 41 | (current-time-query-middleware)) 42 | 43 | (expect (more-> java.util.Date 44 | (-> first :time)) 45 | (current-time-query-middleware-fnarg)) 46 | 47 | (expect (more-> java.util.Date 48 | (-> first :time)) 49 | (mixed-parameters-query {:value1 1 50 | :value2 2 51 | :? [3 4]})) 52 | 53 | (expect empty? 54 | (mixed-parameters-query {:value1 1 55 | :value2 2 56 | :? [0 0]})) 57 | 58 | ;;; Processor functions 59 | (expect (more-> java.util.Date :time) 60 | (current-time-query {} {:result-set-fn first})) 61 | 62 | (expect (more-> java.util.Date first) 63 | (current-time-query {} {:row-fn :time})) 64 | 65 | (expect (more-> java.util.Date (-> first :TIME)) 66 | (current-time-query {} {:identifiers upper-case})) 67 | 68 | (expect java.util.Date 69 | (current-time-query {} {:result-set-fn first 70 | :identifiers clojure.string/upper-case 71 | :row-fn :TIME})) 72 | 73 | ;;; Processor functions with middleware 74 | (expect (more-> java.util.Date :time) 75 | (current-time-query-middleware {} {:result-set-fn first})) 76 | 77 | (expect (more-> java.util.Date first) 78 | (current-time-query-middleware {} {:row-fn :time})) 79 | 80 | (expect (more-> java.util.Date (-> first :TIME)) 81 | (current-time-query-middleware {} {:identifiers upper-case})) 82 | 83 | (expect java.util.Date 84 | (current-time-query-middleware {} {:result-set-fn first 85 | :identifiers clojure.string/upper-case 86 | :row-fn :TIME})) 87 | 88 | ;;; Test comment rules. 89 | (defquery inline-comments-query 90 | "yesql/sample_files/inline_comments.sql" 91 | {:connection derby-db}) 92 | 93 | (expect (more-> java.util.Date :time 94 | "Not -- a comment" :string) 95 | (inline-comments-query {} {:result-set-fn first})) 96 | 97 | ;;; Test Metadata. 98 | (expect (more-> "Just selects the current time.\nNothing fancy." :doc 99 | 'current-time-query :name 100 | (list '[] '[{}] '[{} {:keys [connection]}]) :arglists) 101 | (meta (var current-time-query))) 102 | 103 | (expect (more-> "Here's a query with some named and some anonymous parameters.\n(...and some repeats.)" :doc 104 | 'mixed-parameters-query :name 105 | true (-> :arglists list?) 106 | ;; TODO We could improve the clarity of what this is testing. 107 | 2 (-> :arglists count) 108 | 109 | 1 (-> :arglists first count) 110 | #{'? 'value1 'value2} (-> :arglists first first :keys set) 111 | 112 | 2 (-> :arglists second count) 113 | #{'? 'value1 'value2} (-> :arglists second first :keys set) 114 | #{'connection} (-> :arglists second second :keys set)) 115 | (meta (var mixed-parameters-query))) 116 | 117 | ;; Running a query in a transaction and using the result outside of it should work as expected. 118 | 119 | (let [[{time :time}] (jdbc/with-db-transaction [connection derby-db] 120 | (current-time-query {} 121 | {:connection connection}))] 122 | (expect java.util.Date time)) 123 | 124 | ;;; Check defqueries returns the list of defined vars. 125 | (let [return-value (defqueries "yesql/sample_files/combined_file.sql")] 126 | (expect (repeat 3 clojure.lang.Var) 127 | (map type return-value))) 128 | 129 | ;;; SQL's quoting rules. 130 | (defquery quoting "yesql/sample_files/quoting.sql") 131 | 132 | (expect "'can't'" 133 | (:word (first (quoting {} 134 | {:connection derby-db})))) 135 | 136 | ;;; Switch into a fresh namespace 137 | (ns yesql.core-test.test-require-sql 138 | (:require [expectations :refer :all] 139 | [yesql.core :refer :all])) 140 | 141 | (require-sql ["yesql/sample_files/combined_file.sql" :as combined]) 142 | 143 | (expect var? #'combined/edge) 144 | 145 | (require-sql ["yesql/sample_files/combined_file.sql" :refer [the-time]]) 146 | 147 | (expect var? #'the-time) 148 | -------------------------------------------------------------------------------- /test/yesql/queryfile_parser_test.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.queryfile-parser-test 2 | (:require [clojure.string :refer [join]] 3 | [clojure.template :refer [do-template]] 4 | [expectations :refer :all] 5 | [instaparse.core :as instaparse] 6 | [yesql.queryfile-parser :refer :all] 7 | [yesql.types :refer [map->Query]] 8 | [yesql.util :refer [slurp-from-classpath]]) 9 | (:import [clojure.lang ExceptionInfo])) 10 | 11 | (do-template [start-key input _ expected-output] 12 | (expect (if expected-output 13 | (list expected-output) 14 | (list)) 15 | (instaparse/transform parser-transforms 16 | (instaparse/parses parser input :start start-key))) 17 | 18 | :whitespace " " => " " 19 | :whitespace " \t " => " \t " 20 | :newline "\n" => "\n" 21 | :non-whitespace "abc-DEF" => "abc-DEF" 22 | :any "Test this" => "Test this" 23 | 24 | :comment "--\n" => [:comment "\n"] 25 | :comment "-- This is a comment\n" => [:comment "This is a comment\n"] 26 | :comment " --This is a comment\n" => [:comment "This is a comment\n"] 27 | :comment "-- name: This is not a comment\n" => nil 28 | 29 | :name "--name:test\n" => [:name "test"] 30 | :name "-- name: test\n" => [:name "test"] 31 | 32 | :info "--info-value::test\n" => [:info :value :test] 33 | :info "-- info-value: :test\n" => [:info :value :test] 34 | 35 | :line "SELECT *\n" => "SELECT *\n" 36 | :line "SELECT * FROM dual\n" => "SELECT * FROM dual\n" 37 | :line "SELECT * FROM dual\n" => "SELECT * FROM dual\n" 38 | :line "SELECT * -- with comment\n" => "SELECT * -- with comment\n" 39 | 40 | :query (join "\n" ["-- name: a-query" 41 | "-- This is" 42 | "-- a long comment" 43 | "SELECT * -- With embedded comments." 44 | "FROM dual" 45 | ""]) 46 | => (map->Query {:name "a-query" 47 | :info {} 48 | :docstring "This is\na long comment" 49 | :statement "SELECT * -- With embedded comments.\nFROM dual"}) 50 | 51 | :query (join "\n" ["-- name: query-scalar-info-field" 52 | "-- info-type: :keyword" 53 | "-- This is" 54 | "-- a long comment" 55 | "SELECT * -- With embedded comments." 56 | "FROM dual" 57 | ""]) 58 | => (map->Query {:name "query-scalar-info-field" 59 | :info {:type :keyword} 60 | :docstring "This is\na long comment" 61 | :statement "SELECT * -- With embedded comments.\nFROM dual"}) 62 | 63 | 64 | :query (join "\n" ["-- name: query-vector-info-field" 65 | "-- info-type: [ :x :y ]" 66 | "-- This is" 67 | "-- a long comment" 68 | "SELECT * -- With embedded comments." 69 | "FROM dual" 70 | ""]) 71 | => (map->Query {:name "query-vector-info-field" 72 | :info {:type [ :x :y ]} 73 | :docstring "This is\na long comment" 74 | :statement "SELECT * -- With embedded comments.\nFROM dual"}) 75 | 76 | :query (join "\n" ["-- name: query-multiple-info-fields" 77 | "-- info-f1: :x" 78 | "-- info-f2: :y" 79 | "-- info-f1: :overwrite" 80 | "-- info-f3: [ :z ]" 81 | "-- This is" 82 | "-- a long comment" 83 | "SELECT * -- With embedded comments." 84 | "FROM dual" 85 | ""]) 86 | => (map->Query {:name "query-multiple-info-fields" 87 | :info {:f1 :overwrite :f2 :y :f3 [ :z ]} 88 | :docstring "This is\na long comment" 89 | :statement "SELECT * -- With embedded comments.\nFROM dual"}) 90 | ) 91 | 92 | (expect 93 | [(map->Query {:name "the-time" 94 | :info {} 95 | :docstring "This is another time query.\nExciting, huh?" 96 | :statement "SELECT CURRENT_TIMESTAMP\nFROM SYSIBM.SYSDUMMY1"}) 97 | (map->Query {:name "sums" 98 | :info {} 99 | :docstring "Just in case you've forgotten\nI made you a sum." 100 | :statement (join "\n" ["SELECT" 101 | " :a + 1 adder," 102 | " :b - 1 subtractor" 103 | "FROM SYSIBM.SYSDUMMY1"])}) 104 | (map->Query {:name "edge" 105 | :info {} 106 | :docstring "And here's an edge case.\nComments in the middle of the query." 107 | :statement (join "\n" ["SELECT" 108 | " 1 + 1 AS two" 109 | "FROM SYSIBM.SYSDUMMY1"])})] 110 | (parse-tagged-queries (slurp-from-classpath "yesql/sample_files/combined_file.sql"))) 111 | 112 | ;;; Failures. 113 | (expect #"Parse error" 114 | (try 115 | (parse-tagged-queries (slurp-from-classpath "yesql/sample_files/tagged_no_name.sql")) 116 | (catch ExceptionInfo e (.getMessage e)))) 117 | 118 | (expect #"Parse error" 119 | (try 120 | (parse-tagged-queries (slurp-from-classpath "yesql/sample_files/tagged_two_names.sql")) 121 | (catch ExceptionInfo e (.getMessage e)))) 122 | 123 | ;;; Parsing edge cases. 124 | 125 | (expect ["this-has-trailing-whitespace"] 126 | (map :name 127 | (parse-tagged-queries (slurp-from-classpath "yesql/sample_files/parser_edge_cases.sql")))) 128 | -------------------------------------------------------------------------------- /src/yesql/generate.clj: -------------------------------------------------------------------------------- 1 | (ns yesql.generate 2 | (:require [clojure.java.jdbc :as jdbc] 3 | [clojure.set :as set] 4 | [clojure.string :refer [join lower-case]] 5 | [yesql.util :refer [create-root-var]] 6 | [yesql.types :refer [map->Query]] 7 | [yesql.statement-parser :refer [tokenize]]) 8 | (:import [yesql.types Query])) 9 | 10 | (def in-list-parameter? 11 | "Check if a type triggers IN-list expansion." 12 | (some-fn list? vector? seq?)) 13 | 14 | (defn- args-to-placeholders 15 | [args] 16 | (if (in-list-parameter? args) 17 | (clojure.string/join "," (repeat (count args) "?")) 18 | "?")) 19 | 20 | (defn- analyse-statement-tokens 21 | [tokens] 22 | {:expected-keys (set (map keyword (remove (partial = '?) 23 | (filter symbol? tokens)))) 24 | :expected-positional-count (count (filter (partial = '?) 25 | tokens))}) 26 | 27 | (defn expected-parameter-list 28 | [query] 29 | (let [tokens (tokenize query) 30 | {:keys [expected-keys expected-positional-count]} (analyse-statement-tokens tokens)] 31 | (if (zero? expected-positional-count) 32 | expected-keys 33 | (conj expected-keys :?)))) 34 | 35 | (defn rewrite-query-for-jdbc 36 | [tokens initial-args] 37 | (let [{:keys [expected-keys expected-positional-count]} (analyse-statement-tokens tokens) 38 | actual-keys (set (keys (dissoc initial-args :?))) 39 | actual-positional-count (count (:? initial-args)) 40 | missing-keys (set/difference expected-keys actual-keys)] 41 | (assert (empty? missing-keys) 42 | (format "Query argument mismatch.\nExpected keys: %s\nActual keys: %s\nMissing keys: %s" 43 | (str (seq expected-keys)) 44 | (str (seq actual-keys)) 45 | (str (seq missing-keys)))) 46 | (assert (= expected-positional-count actual-positional-count) 47 | (format (join "\n" 48 | ["Query argument mismatch." 49 | "Expected %d positional parameters. Got %d." 50 | "Supply positional parameters as {:? [...]}"]) 51 | expected-positional-count actual-positional-count)) 52 | (let [[final-query final-parameters consumed-args] 53 | (reduce (fn [[query parameters args] token] 54 | (cond 55 | (string? token) [(str query token) 56 | parameters 57 | args] 58 | (symbol? token) (let [[arg new-args] (if (= '? token) 59 | [(first (:? args)) (update-in args [:?] rest)] 60 | [(get args (keyword token)) args])] 61 | [(str query (args-to-placeholders arg)) 62 | (vec (if (in-list-parameter? arg) 63 | (concat parameters arg) 64 | (conj parameters arg))) 65 | new-args]))) 66 | ["" [] initial-args] 67 | tokens)] 68 | (concat [final-query] final-parameters)))) 69 | 70 | ;; Maintainer's note: clojure.java.jdbc.execute! returns a list of 71 | ;; rowcounts, because it takes a list of parameter groups. In our 72 | ;; case, we only ever use one group, so we'll unpack the 73 | ;; single-element list with `first`. 74 | (defn execute-handler 75 | [db sql-and-params call-options] 76 | (first (jdbc/execute! db sql-and-params))) 77 | 78 | (defn insert-handler 79 | [db statement-and-params call-options] 80 | (jdbc/db-do-prepared-return-keys db statement-and-params)) 81 | 82 | (defn query-handler 83 | [db sql-and-params 84 | {:keys [row-fn result-set-fn identifiers] 85 | :or {identifiers lower-case 86 | row-fn identity 87 | result-set-fn doall} 88 | :as call-options}] 89 | (jdbc/query db sql-and-params 90 | {:identifiers identifiers 91 | :row-fn row-fn 92 | :result-set-fn result-set-fn})) 93 | 94 | (defn generate-query-fn 95 | "Generate a function to run a query. 96 | 97 | - If the query name ends in `!` it will call `clojure.java.jdbc/execute!`, 98 | - If the query name ends in ` ({:name "Kris" :country_code "GB" ...} ...) 101 | ``` 102 | 103 | By keeping the SQL and Clojure separate you get: 104 | 105 | - No syntactic surprises. Your database doesn't stick to the SQL 106 | standard - none of them do - but Yesql doesn't care. You will 107 | never spend time hunting for "the equivalent sexp syntax". You will 108 | never need to fall back to a `(raw-sql "some('funky'::SYNTAX)")` function. 109 | - Better editor support. Your editor probably already has great SQL 110 | support. By keeping the SQL as SQL, you get to use it. 111 | - Team interoperability. Your DBAs can read and write the SQL you 112 | use in your Clojure project. 113 | - Easier performance tuning. Need to `EXPLAIN` that query plan? It's 114 | much easier when your query is ordinary SQL. 115 | - Query reuse. Drop the same SQL files into other projects, because 116 | they're just plain ol' SQL. Share them as a submodule. 117 | 118 | ### When Should I Not Use Yesql? 119 | 120 | When you need your SQL to work with many different kinds of 121 | database at once. If you want one complex query to be transparently 122 | translated into different dialects for MySQL, Oracle, Postgres etc., 123 | then you genuinely do need an abstraction layer on top of SQL. 124 | 125 | ## Usage 126 | ### One File, One Query 127 | 128 | Create an SQL query. Note we can supply named parameters ([in 129 | `snake_case`](https://github.com/krisajenkins/yesql/issues/1)) 130 | and a comment string: 131 | 132 | ```sql 133 | -- Counts the users in a given country. 134 | SELECT count(*) AS count 135 | FROM user 136 | WHERE country_code = :country_code 137 | ``` 138 | 139 | Make sure it's on the classpath. For this example, it's in 140 | `src/some/where/`. Now we can use it in our Clojure program. 141 | 142 | ```clojure 143 | (require '[yesql.core :refer [defquery]]) 144 | 145 | ; Define a database connection spec. (This is standard clojure.java.jdbc.) 146 | (def db-spec {:classname "org.postgresql.Driver" 147 | :subprotocol "postgresql" 148 | :subname "//localhost:5432/demo" 149 | :user "me"}) 150 | 151 | ; Import the SQL query as a function. 152 | (defquery users-by-country "some/where/users_by_country.sql" 153 | {:connection db-spec}) 154 | ``` 155 | 156 | Lo! The function has been created, with automatic, useful docstrings 157 | in the REPL: 158 | 159 | ```clojure 160 | (clojure.repl/doc users-by-country) 161 | 162 | ;=> ------------------------- 163 | ;=> user/users-by-country 164 | ;=> ([{:keys [country_code]}] 165 | ;=> [{:keys [country_code]} {:keys [connection]}]) 166 | ;=> 167 | ;=> Counts the users in a given country. 168 | ``` 169 | 170 | Now we can use it: 171 | 172 | ```clojure 173 | ; Use it standalone. 174 | (users-by-country {:country_code "GB"}) 175 | ;=> ({:count 58}) 176 | 177 | ; Use it in a clojure.java.jdbc transaction. 178 | (require '[clojure.java.jdbc :as jdbc]) 179 | 180 | (jdbc/with-db-transaction [tx db-spec] 181 | {:limeys (users-by-country {:country_code "GB"} {:connection tx}) 182 | :yanks (users-by-country {:country_code "US"} {:connection tx})}) 183 | ``` 184 | 185 | ### One File, Many Queries 186 | 187 | As an alternative to the above, you can have many SQL statements in a 188 | single SQL file. The file format is: `( [docstring comments] 189 | )*`, like so: 190 | 191 | ``` sql 192 | -- name: users-by-country 193 | -- Counts the users in a given country. 194 | SELECT count(*) AS count 195 | FROM user 196 | WHERE country_code = :country_code 197 | 198 | -- name: user-count 199 | -- Counts all the users. 200 | SELECT count(*) AS count 201 | FROM user 202 | ``` 203 | 204 | Then read the file in like so: 205 | 206 | ```clojure 207 | (require '[yesql.core :refer [defqueries]]) 208 | (defqueries "some/where/queryfile.sql" 209 | {:connection db-spec}) 210 | ``` 211 | 212 | `defqueries` returns a sequence of the vars it binds, which can be 213 | useful feedback while developing. 214 | 215 | As with `defquery`, each function will have a docstring based on the 216 | comments, and a parameter map based on the SQL parameters. 217 | 218 | ### ? Parameters 219 | 220 | Yesql supports named parameters, and `?`-style positional 221 | parameters. Here's an example: 222 | 223 | ```sql 224 | -- name: young-users-by-country 225 | SELECT * 226 | FROM user 227 | WHERE ( 228 | country_code = ? 229 | OR 230 | country_code = ? 231 | ) 232 | AND age < :max_age 233 | ``` 234 | 235 | Supply the `?` parameters as a vector under the `:?` key, like so: 236 | 237 | ```clojure 238 | (young-users-by-country {:? ["GB" "US"] 239 | :max_age 18}) 240 | ``` 241 | 242 | #### Selectively import queries 243 | 244 | Similarly to `defqueries`, `require-sql` lets you create a number of 245 | query functions at a time, but with a syntax more like 246 | `clojure.core/require`. 247 | 248 | Using the `queryfile.sql` from the previous example: 249 | 250 | ```clojure 251 | (require '[yesql.core :refer [require-sql]]) 252 | 253 | ; Use :as to alias the entire namespace, and :refer to refer functions 254 | ; into the current namespace. Use one or both. 255 | (require-sql ["some/where/queryfile.sql" :as user :refer [user-count]) 256 | 257 | (user-count) 258 | ;=> ({:count 132}) 259 | 260 | (user/users-by-country db-spec "GB") 261 | ;=> ({:count 58}) 262 | ``` 263 | 264 | ### IN-list Queries 265 | 266 | Yesql supports `IN`-style queries. Define your query with a 267 | single-element in the `IN` list, like so: 268 | 269 | ```sql 270 | -- name: find-users 271 | -- Find the users with the given ID(s). 272 | SELECT * 273 | FROM user 274 | WHERE user_id IN (:id) 275 | AND age > :min_age 276 | ``` 277 | 278 | And then supply the `IN`-list as a vector, like so: 279 | 280 | ```clojure 281 | (defqueries "some/where/queryfile.sql" 282 | {:connection db-spec}) 283 | 284 | (find-users {:id [1001 1003 1005] 285 | :min_age 18}) 286 | ``` 287 | 288 | The query will be automatically expanded to `... IN (1001, 1003, 1005) 289 | ...` under the hood, and work as expected. 290 | 291 | Just remember that some databases have a limit on the number of values 292 | in an `IN`-list, and Yesql makes no effort to circumvent such limits. 293 | 294 | ### Row And Result Processors 295 | 296 | Like `clojure.java.jdbc`, Yesql accepts functions to pre-process each 297 | row, and the final result, like so: 298 | 299 | ```sql 300 | -- name: current-time 301 | -- Selects the current time, according to the database. 302 | SELECT sysdate 303 | FROM dual; 304 | ``` 305 | 306 | ```clojure 307 | (defqueries "/some/where/queryfile.sql" 308 | {:connection db-spec}) 309 | 310 | ;;; Without processors, this query returns a list with one element, 311 | ;;; containing a map with one key: 312 | (current-time) 313 | ;=> ({:sysdate #inst "2014-09-30T07:30:06.764000000-00:00"}) 314 | 315 | ;;; With processors we just get the value we want: 316 | (current-time {} {:result-set-fn first 317 | :row-fn :sysdate 318 | :identifiers identity}) 319 | ;=> #inst "2014-09-30T07:30:06.764000000-00:00" 320 | ``` 321 | 322 | As with `clojure.java.jdbc` the default `:result-set-fn` is `doall`, 323 | the default `:row-fn` is `identity`, and the default `:identifiers` is 324 | `clojure.string/lower-case`. 325 | 326 | _A note of caution_: Remember you're often better off doing your 327 | processing directly in SQL. For example, if you're counting a million 328 | rows, you can do it with `{:result-set-fn count}` or 329 | `SELECT count(*) ...`. Both wil give the same answer, but the 330 | SQL-version will avoid sending a million rows over the wire to do it. 331 | 332 | ### Insert/Update/Delete and More 333 | 334 | To do `INSERT/UPDATE/DELETE` statements, you just need to add an `!` 335 | to the end of the function name, and Yesql will execute the function 336 | appropriately. For example: 337 | 338 | ```sql 339 | -- name: save-person! 340 | UPDATE person 341 | SET name = :name 342 | WHERE id = :id 343 | ``` 344 | 345 | ```clojure 346 | (save-person! {:id 1 347 | :name "Dave"}) 348 | ;=> 1 349 | ``` 350 | 351 | A `!`-tagged function will return the number of rows affected. 352 | 353 | `!` enables every statement type - not just `INSERT/UPDATE/DELETE` but 354 | also `CREATE/DROP/ALTER/BEGIN/...` - anything your driver will 355 | support. 356 | 357 | ### Insert, Returning Autogenerated Keys 358 | 359 | There's one more variant: when you want to insert data and get back a 360 | database-generated primary key, the driver requires a special call, so 361 | Yesql needs to be specially-informed. You can do an "insert returning 362 | autogenerated key" with the ` {:name "Dave" :id 5} 372 | ``` 373 | 374 | The exact return value will depend on your database driver. For 375 | example PostgreSQL returns the whole row, whereas Derby returns just 376 | `{:1 5M}`. 377 | 378 | The `