diff --git a/src/toucan/db.clj b/src/toucan/db.clj index 4458445..b41ce64 100644 --- a/src/toucan/db.clj +++ b/src/toucan/db.clj @@ -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` @@ -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! @@ -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. diff --git a/test/toucan/db_test.clj b/test/toucan/db_test.clj index 8865b9c..0fbfd96 100644 --- a/test/toucan/db_test.clj +++ b/test/toucan/db_test.clj @@ -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]]]) @@ -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"} diff --git a/test/toucan/test_models/pg_enum.clj b/test/toucan/test_models/pg_enum.clj new file mode 100644 index 0000000..9ca76bb --- /dev/null +++ b/test/toucan/test_models/pg_enum.clj @@ -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})) diff --git a/test/toucan/test_setup.clj b/test/toucan/test_setup.clj index 9551c6f..67e68ee 100644 --- a/test/toucan/test_setup.clj +++ b/test/toucan/test_setup.clj @@ -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"))