Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add batch inserts #135

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ in the REPL:

;=> -------------------------
;=> user/users-by-country
;=> ([{:keys [country_code]}]
;=> ([{:keys [country_code]}]
;=> [{:keys [country_code]} {:keys [connection]}])
;=>
;=> Counts the users in a given country.
Expand Down Expand Up @@ -351,6 +351,13 @@ INSERT INTO person (name) VALUES (:name)
;=> {:name "Dave" :id 5}
```

To batch insert, Yesql will take a vector of maps.

```clojure
(create-person<! [{:name "Dave"} {:name "Jill"}])
;=> '({:name "Dave" :id 5} {:name "Jill" :id 6})
```

The exact return value will depend on your database driver. For
example PostgreSQL returns the whole row, whereas Derby returns just
`{:1 5M}`.
Expand Down
2 changes: 1 addition & 1 deletion project.clj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
(defproject yesql "0.5.2"
(defproject yesql "0.6.0"
:description "A Clojure library for using SQL"
:url "https://github.com/krisajenkins/yesql"
:license {:name "Eclipse Public License"
Expand Down
70 changes: 41 additions & 29 deletions src/yesql/generate.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[clojure.string :refer [join lower-case]]
[yesql.util :refer [create-root-var]]
[yesql.types :refer [map->Query]]
[yesql.statement-parser :refer [tokenize]])
[yesql.statement-parser :refer [tokenize insert-table-name-regex]])
(:import [yesql.types Query]))

(def in-list-parameter?
Expand Down Expand Up @@ -32,10 +32,11 @@
expected-keys
(conj expected-keys :?))))

(defn rewrite-query-for-jdbc
[tokens initial-args]
(defn sane-query? [tokens initial-args]
(let [{:keys [expected-keys expected-positional-count]} (analyse-statement-tokens tokens)
actual-keys (set (keys (dissoc initial-args :?)))
actual-keys (if (vector? initial-args)
(-> (mapcat keys initial-args) set (disj :?))
(-> (keys initial-args) set (disj :?)))
actual-positional-count (count (:? initial-args))
missing-keys (set/difference expected-keys actual-keys)]
(assert (empty? missing-keys)
Expand All @@ -48,45 +49,55 @@
["Query argument mismatch."
"Expected %d positional parameters. Got %d."
"Supply positional parameters as {:? [...]}"])
expected-positional-count actual-positional-count))
(let [[final-query final-parameters consumed-args]
(reduce (fn [[query parameters args] token]
(cond
(string? token) [(str query token)
parameters
args]
(symbol? token) (let [[arg new-args] (if (= '? token)
[(first (:? args)) (update-in args [:?] rest)]
[(get args (keyword token)) args])]
[(str query (args-to-placeholders arg))
(vec (if (in-list-parameter? arg)
(concat parameters arg)
(conj parameters arg)))
new-args])))
["" [] initial-args]
tokens)]
(concat [final-query] final-parameters))))
expected-positional-count actual-positional-count))))

(defn rewrite-query-for-jdbc
[tokens initial-args]
(sane-query? tokens initial-args)
(let [[final-query final-parameters consumed-args]
(reduce (fn [[query parameters args] token]
(cond
(string? token) [(str query token)
parameters
args]
(symbol? token) (let [[arg new-args] (if (= '? token)
[(first (:? args)) (update-in args [:?] rest)]
[(get args (keyword token)) args])]
[(str query (args-to-placeholders arg))
(vec (if (in-list-parameter? arg)
(concat parameters arg)
(conj parameters arg)))
new-args])))
["" [] initial-args]
tokens)]
(concat [final-query] final-parameters)))

;; Maintainer's note: clojure.java.jdbc.execute! returns a list of
;; rowcounts, because it takes a list of parameter groups. In our
;; case, we only ever use one group, so we'll unpack the
;; single-element list with `first`.
(defn execute-handler
[db sql-and-params call-options]
(first (jdbc/execute! db sql-and-params)))
[db sql params call-options]
(first (jdbc/execute! db (rewrite-query-for-jdbc sql params))))

(defn insert-handler
[db [statement & params] call-options]
(jdbc/db-do-prepared-return-keys db statement params))
[db sql params call-options]
(if (vector? params)
(let [full-query (apply str sql)
table-name (re-find insert-table-name-regex full-query)]
(sane-query? sql params)
(apply jdbc/insert! db table-name params))
(let [[rewritten-sql & rewritten-params] (rewrite-query-for-jdbc sql params)]
(jdbc/db-do-prepared-return-keys db rewritten-sql rewritten-params))))

(defn query-handler
[db sql-and-params
[db sql params
{:keys [row-fn result-set-fn identifiers]
:or {identifiers lower-case
row-fn identity
result-set-fn doall}
:as call-options}]
(jdbc/query db sql-and-params
(jdbc/query db (rewrite-query-for-jdbc sql params)
:identifiers identifiers
:row-fn row-fn
:result-set-fn result-set-fn))
Expand Down Expand Up @@ -118,7 +129,8 @@
"Check the docs, and supply {:connection ...} as an option to the function call, or globally to the defquery declaration."])
name))
(jdbc-fn connection
(rewrite-query-for-jdbc tokens args)
tokens
args
call-options)))
[display-args generated-function] (let [named-args (if-let [as-vec (seq (mapv (comp symbol clojure.core/name)
required-args))]
Expand Down
2 changes: 2 additions & 0 deletions src/yesql/statement_parser.clj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
[yesql.instaparse-util :refer [process-instaparse-result]])
(:import [yesql.types Query]))

(def insert-table-name-regex #"(?i)(?<=INSERT INTO\s).*?(?=\sVALUES|\s\()")

(def parser
(instaparse/parser (io/resource "yesql/statement.bnf")))

Expand Down
40 changes: 21 additions & 19 deletions test/yesql/acceptance_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,14 @@
(expect (create-person-table!))

;; Insert -> Select.
(expect {:1 1M} (insert-person<! {:name "Alice"
:age 20}))
(expect {:1 2M} (insert-person<! {:name "Bob"
:age 25}))
(expect {:1 3M} (insert-person<! {:name "Charlie"
:age 35}))
(expect {:1 1M} (insert-person<! {:name "Alice" :age 20}))
(expect {:1 2M} (insert-person<! {:name "Bob" :age 25}))
(expect {:1 3M} (insert-person<! {:name "Charlie" :age 35}))
(expect (list {:1 4M} {:1 5M}) (insert-person<! [{:name "Pepper" :age 50} {:name "Tony" :age 55}]))

(expect 3 (count (find-older-than {:age 10})))
(expect 1 (count (find-older-than {:age 30})))
(expect 0 (count (find-older-than {:age 50})))
(expect 5 (count (find-older-than {:age 10})))
(expect 3 (count (find-older-than {:age 30})))
(expect 1 (count (find-older-than {:age 50})))

;;; Select with IN.
(expect 2 (count (find-by-age {:age [20 35]})))
Expand All @@ -46,20 +44,20 @@
(expect 0 (update-age! {:age 38
:name "David"}))

(expect 3 (count (find-older-than {:age 10})))
(expect 2 (count (find-older-than {:age 30})))
(expect 0 (count (find-older-than {:age 50})))
(expect 5 (count (find-older-than {:age 10})))
(expect 4 (count (find-older-than {:age 30})))
(expect 1 (count (find-older-than {:age 50})))

;; Delete -> Select.
(expect 1 (delete-person! {:name "Alice"}))

(expect 2 (count (find-older-than {:age 10})))
(expect 1 (count (find-older-than {:age 30})))
(expect 0 (count (find-older-than {:age 50})))
(expect 4 (count (find-older-than {:age 10})))
(expect 3 (count (find-older-than {:age 30})))
(expect 1 (count (find-older-than {:age 50})))

;; Failing transaction: Insert with abort.
;; Insert two rows in a transaction. The second throws a deliberate error, meaning no new rows created.
(expect 2 (count (find-older-than {:age 10})))
(expect 4 (count (find-older-than {:age 10})))

(expect SQLException
(jdbc/with-db-transaction [connection derby-db]
Expand All @@ -68,17 +66,21 @@
{:connection connection})
(insert-person<! {:name "Bob"
:age 25}
{:connection connection} )))
{:connection connection})))

(expect 2
(count (find-older-than {:age 10})))
(expect 4 (count (find-older-than {:age 10})))

;;; Type error.
(expect SQLDataException
(insert-person<! {:name 5
:age "Eamonn"}
{:connection derby-db}))

;;; Attempt insert with incorrect parameters
(expect SQLSyntaxErrorException
(insert-person<! [{:name "Mary" :height 20}
{:name "Parker" :age 20}]))

;; Drop
(expect (drop-person-table!))

Expand Down
17 changes: 12 additions & 5 deletions test/yesql/generate_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,20 @@
"SELECT * FROM users WHERE group_ids IN(:group_ids) AND parent_id = :parent_id"
{:group_ids [1 2]
:parent_id 3}
=> ["SELECT * FROM users WHERE group_ids IN(?,?) AND parent_id = ?" 1 2 3])
=> ["SELECT * FROM users WHERE group_ids IN(?,?) AND parent_id = ?" 1 2 3])

;;; Incorrect parameters.
(expect AssertionError
(rewrite-query-for-jdbc (tokenize "SELECT age FROM users WHERE country = :country AND name = :name")
{:country "gb"}))
(sane-query?
(tokenize "SELECT age FROM users WHERE country = :country AND name = :name")
{:country "gb"}))

(expect AssertionError
(rewrite-query-for-jdbc (tokenize "SELECT age FROM users WHERE country = ? AND name = ?")
{}))
(sane-query?
(tokenize "SELECT age FROM users WHERE country = ? AND name = ?")
{}))

(expect AssertionError
(sane-query?
(tokenize "INSERT INTO users (country, name) VALUES (:country, :name)")
[{:country "gb"} {:country "us"}]))
10 changes: 10 additions & 0 deletions test/yesql/statement_parser_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
[yesql.types :refer [map->Query]]
[yesql.statement-parser :refer :all]))

(do-template [statement _ table-name-result]
(do (expect (quote table-name-result)
(re-find insert-table-name-regex statement)))

"INSERT INTO table (:column1, :column2) VALUES (a, b)" => "table"
"insert into table (:column1, :column2) VALUES (a, b)" => "table"
"INSERT INTO table VALUES (a, b)" => "table"
"insert into table VALUES (a, b)" => "table"
"insert into table values (a, b)" => "table")

(do-template [statement _ split-result]
(do (expect (quote split-result)
(tokenize statement))
Expand Down