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

Fix/custom types select queries #89

Merged
merged 4 commits into from
Jan 21, 2022
Merged
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
35 changes: 30 additions & 5 deletions src/toucan/db.clj
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,30 @@
([honeysql-form k v & more]
(apply where (where honeysql-form k v) more)))

(defn- where*
"Generate a HoneySQL `where` form using key-value args + apply type fn to the value in all possible cases,
i.e. to a plain vanilla value and to a vector form of applying a function to a value (e.g. `!=` or `in`).

(where {} :a :b) -> (h/merge-where {} [:= :a :b])
(where {} :a [:!= b]) -> (h/merge-where {} [:!= :a :b])
(where {} {:a [:!= b]}) -> (h/merge-where {} [:!= :a :b])"
{:style/indent 1}
[model honeysql-form k v]
(let [do-type-fn #(models/do-type-fn model k % :in)]
(h/merge-where honeysql-form (if (vector? v)
(let [[f & [first-arg & rest-args]] v ; e.g. :id [:!= 1] -> [:!= :id 1]
_ (assert (keyword? f))
args (case f
;; form is [:in #{...}] => first-arg=#{...}, rest-args=nil
(:in :not-in) (list (map do-type-fn first-arg))
;; form is [:between a b] => first-arg=a, rest-args=(b)
:between (list (do-type-fn first-arg)
(do-type-fn (first rest-args)))
;; all other forms comply with this default syntax
(cons (do-type-fn first-arg) rest-args))]
(vec (cons f (cons k args))))
[:= k (do-type-fn v)]))))

(defn- where+
"Generate a HoneySQL form, converting pairs of arguments with keywords into a `where` clause, and merging other
HoneySQL clauses in as-is. Meant for internal use by functions like `select`. (So-called because it handles `where`
Expand All @@ -459,9 +483,8 @@
[model honeysql-form args]
(loop [honeysql-form honeysql-form, [first-arg & [second-arg & more, :as butfirst]] args]
(cond
(keyword? first-arg) (recur (where honeysql-form first-arg
(models/do-type-fn model first-arg second-arg :in)) more)
first-arg (recur (merge honeysql-form first-arg) butfirst)
(keyword? first-arg) (recur (where* model honeysql-form first-arg second-arg) more)
first-arg (recur (merge honeysql-form first-arg) butfirst)
:else honeysql-form)))

;;; ### UPDATE!
Expand Down Expand Up @@ -624,8 +647,10 @@
(select-one ['Database :name] :id 1) -> {:name \"Sample Dataset\"}"
{:style/indent 1}
[model & options]
(let [fields (model->fields model)]
(simple-select-one model (where+ (resolve-model model) {:select (or fields [:*])} options))))
(simple-select-one model (where+ (resolve-model model)
{:select (or (model->fields model)
[:*])}
options)))

(defn select-one-field
"Select a single `field` of a single object from the database.
Expand Down
8 changes: 8 additions & 0 deletions test/toucan/db_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
[address :refer [Address]]
[category :refer [Category]]
[food :refer [Food]]
[pg-enum :refer [TypedThing]]
[phone-number :refer [PhoneNumber]]
[user :refer [User]]
[venue :refer [Venue]]])
Expand Down Expand Up @@ -294,6 +295,13 @@
(db/update! Food "F4" :price 42.42M)
(db/select-one Food :id "F4")))

(expect
#toucan.test_models.pg_enum.TypedThingInstance{:id 1, :type :thing-type/type-2}
(test/with-clean-db
(db/insert! TypedThing {:id 1 :type :thing-type/type-1})
(db/update! TypedThing 1 :type :thing-type/type-2)
(db/select-one TypedThing :type [:= :thing-type/type-2])))

;; Test update-where!
(expect
[#toucan.test_models.user.UserInstance{:id 1, :first-name "Cam", :last-name "Saul"}
Expand Down
69 changes: 69 additions & 0 deletions test/toucan/test_models/pg_enum.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
(ns toucan.test-models.pg-enum
"A model with a PostgreSQL ENUM typed attribute."
(:require [clojure.java.jdbc :refer [IResultSetReadColumn ISQLValue]]
[clojure.string :as str]
[toucan.models :as models])
(:import [clojure.lang Keyword]
[java.sql ResultSetMetaData]
[org.postgresql.util PGobject]))

(def ^:private enum-types
"A set of all PostgreSQL ENUM types in the DB schema.
Used to convert enum values back into Clojure keywords."
#{"thing_type"})

(defn- ^PGobject kwd->pg-enum
[kwd]
(let [type (-> (namespace kwd)
(str/replace "-" "_"))
value (name kwd)]
(doto (PGobject.)
(.setType type)
(.setValue value))))

;; NB: This way of using custom datatype is ignored by HoneySQL,
;; but for `clojure.java.jdbc` this is an idiomatic approach
;; to its implementation, thus we prefer it over the issue #80.
(extend-protocol ISQLValue
Keyword
(sql-value [kwd]
(let [pg-obj (kwd->pg-enum kwd)
type (.getType pg-obj)]
(if (contains? enum-types type)
pg-obj
kwd))))

(defn- pg-enum->kwd
([^PGobject pg-obj]
(pg-enum->kwd (.getType pg-obj)
(.getValue pg-obj)))
([type val]
{:pre [(not (str/blank? type))
(not (str/blank? val))]}
(keyword (str/replace type "_" "-") val)))

(extend-protocol IResultSetReadColumn
String
(result-set-read-column [val ^ResultSetMetaData rsmeta idx]
(let [type (.getColumnTypeName rsmeta idx)]
(if (contains? enum-types type)
(pg-enum->kwd type val)
val)))

PGobject
(result-set-read-column [val _ _]
(if (contains? enum-types (.getType val))
(pg-enum->kwd val)
val)))

;; NB: This one is different from the plain ':keyword' by the fact
;; that an additional DB ENUM type values check is carried out.
(models/add-type!
:enum
:in kwd->pg-enum
:out identity)

(models/defmodel TypedThing :typed
models/IModel
(types [_]
{:type :enum}))
13 changes: 12 additions & 1 deletion test/toucan/test_setup.clj
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,18 @@
id BYTEA PRIMARY KEY,
price DECIMAL(10,2) NOT NULL
);"
"TRUNCATE TABLE foods;"))
"TRUNCATE TABLE foods;"
;; TypedThing
"DROP TYPE IF EXISTS thing_type CASCADE;"
"CREATE TYPE thing_type AS ENUM (
'type-1',
'type-2'
);"
"DROP TABLE IF EXISTS typed;"
"CREATE TABLE IF NOT EXISTS typed (
id SERIAL PRIMARY KEY,
type thing_type NOT NULL
);"))


(def ^java.sql.Timestamp jan-first-2017 (Timestamp/valueOf "2017-01-01 00:00:00"))
Expand Down