Skip to content

Commit

Permalink
Namespaced keyword support (#21)
Browse files Browse the repository at this point in the history
* Namespaces should work with transforms

* Add alias based on namespace so keywords work as well

* Namespaced insert support

* Support namespaces inside `update!`

* Support namespaced keywords in delete

* Lint fix
  • Loading branch information
camsaul authored Aug 30, 2022
1 parent 11209af commit 4d5d93d
Show file tree
Hide file tree
Showing 17 changed files with 369 additions and 64 deletions.
3 changes: 1 addition & 2 deletions src/toucan2/delete.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"Implementation of [[delete!]]."
(:require
[methodical.core :as m]
[toucan2.model :as model]
[toucan2.pipeline :as pipeline]
[toucan2.query :as query]))

Expand All @@ -11,7 +10,7 @@
#_query clojure.lang.IPersistentMap]
[query-type model parsed-args]
(let [parsed-args (update parsed-args :query (fn [query]
(merge {:delete-from [(keyword (model/table-name model))]}
(merge {:delete-from (query/honeysql-table-and-alias model)}
query)))]
(next-method query-type model parsed-args)))

Expand Down
36 changes: 23 additions & 13 deletions src/toucan2/jdbc/result_set.clj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
[pretty.core :as pretty]
[toucan2.instance :as instance]
[toucan2.jdbc.row :as jdbc.row]
[toucan2.magic-map :as magic-map]
[toucan2.model :as model]
[toucan2.protocols :as protocols]
[toucan2.util :as u])
(:import
Expand Down Expand Up @@ -53,7 +55,7 @@
[^ResultSetMetaData rsmeta]
(range 1 (inc (.getColumnCount rsmeta))))

(defn- row-instance [model #_key-xform col-name->thunk]
(defn- row-instance [model col-name->thunk]
(let [row (jdbc.row/row col-name->thunk)]
(u/with-debug-result ["Creating new instance of %s, which has key transform fn %s"
model
Expand All @@ -63,21 +65,29 @@
(defn row-thunk
"Return a thunk that when called fetched the current row from the cursor and returns it as a [[row-instance]]."
[^Connection conn model ^ResultSet rset]
(let [rsmeta (.getMetaData rset)
key-xform (instance/key-transform-fn model)
(let [rsmeta (.getMetaData rset)
;; do case-insensitive lookup.
table->namespace (some-> (model/table-name->namespace model) (magic-map/magic-map u/lower-case-en))
key-xform (instance/key-transform-fn model)
;; create a set of thunks to read each column. These thunks will call `read-column-thunk` to determine the
;; appropriate column-reading thunk the first time they are used.
col-name->thunk (into {} (for [^Long i (index-range rsmeta)
:let [col-name (key-xform (keyword (.getColumnName rsmeta i)))
;; TODO -- add test to ensure we only resolve the read-column-thunk
;; once even with multiple rows.
read-thunk (delay (read-column-thunk conn model rset rsmeta i))
result-thunk (fn []
(u/with-debug-result ["Realize column %s %s" i col-name]
(next.jdbc.rs/read-column-by-index (@read-thunk) rsmeta i)))]]
[col-name result-thunk]))]
col-name->thunk (into {}
(map (fn [^Long i]
(let [table-name (.getTableName rsmeta i)
col-name (.getColumnName rsmeta i)
table-ns-name (some-> (get table->namespace table-name) name)
col-key (key-xform (keyword table-ns-name col-name))
;; TODO -- add test to ensure we only resolve the read-column-thunk
;; once even with multiple rows.
read-thunk (delay (read-column-thunk conn model rset rsmeta i))
result-thunk (fn []
(u/with-debug-result ["Realize column %s %s.%s as %s"
i table-name col-name col-key]
(next.jdbc.rs/read-column-by-index (@read-thunk) rsmeta i)))]
[col-key result-thunk])))
(index-range rsmeta))]
(fn row-instance-thunk []
(row-instance model #_key-xform col-name->thunk))))
(row-instance model col-name->thunk))))

(deftype ^:no-doc ReducibleResultSet [^Connection conn model ^ResultSet rset]
clojure.lang.IReduceInit
Expand Down
38 changes: 33 additions & 5 deletions src/toucan2/model.clj
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
(ns toucan2.model
(:refer-clojure :exclude [namespace])
(:require
[clojure.spec.alpha :as s]
[methodical.core :as m]
Expand Down Expand Up @@ -78,7 +79,7 @@
If an implementation returns a single keyword, the default `:around` method will automatically wrap it in a vector. It
also validates that the ultimate result is a sequence of keywords, so it is safe to assume that calls to this will
always return a sequence of keywords."
{:arglists '([model])}
{:arglists '([model])}
u/dispatch-on-first-arg)

;;; if the PK comes back unwrapped, wrap it.
Expand All @@ -96,10 +97,6 @@
{:model model, :result pk-or-pks})))
pks))

(m/defmethod primary-keys :default
[_model]
[:id])

;;; TODO -- rename to `primary-key-values-map`
(defn primary-key-values
"Return a map of primary key values for a Toucan 2 `instance`."
Expand All @@ -121,3 +118,34 @@
(if (= (count pk-keys) 1)
(first pk-keys)
(apply juxt pk-keys)))))

(m/defmulti model->namespace
{:arglists '([model₁])}
u/dispatch-on-first-arg)

(m/defmethod model->namespace :default
[_model]
nil)

(defn table-name->namespace [model]
(not-empty
(into {}
(comp (filter (fn [[model _a-namespace]]
(not= (m/effective-primary-method table-name model)
(m/default-effective-method table-name))))
(map (fn [[model a-namespace]]
[(table-name model) a-namespace])))
(model->namespace model))))

(defn namespace [model]
(some
(fn [[a-model a-namespace]]
(when (isa? model a-model)
a-namespace))
(model->namespace model)))

(m/defmethod primary-keys :default
[model]
(if-let [model-namespace (namespace model)]
[(keyword (name model-namespace) "id")]
[:id]))
22 changes: 21 additions & 1 deletion src/toucan2/query.clj
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
Compiled query is executed with connection
```"

(:require
[better-cond.core :as b]
[clojure.spec.alpha :as s]
[honey.sql.helpers :as hsql.helpers]
[methodical.core :as m]
Expand Down Expand Up @@ -418,3 +418,23 @@
(m/defmethod build [:default :default clojure.lang.IPersistentMap]
[_query-type model {:keys [kv-args query], :as _args}]
(apply-kv-args model query kv-args))

(defn honeysql-table-and-alias
"Build an Honey SQL `[table]` or `[table alias]` (if the model has a [[toucan2.model/namespace]] form) for `model` for
use in something like a `:select` clause."
[model]
(b/cond
:let [table-id (keyword (model/table-name model))
alias-id (model/namespace model)
alias-id (when alias-id
(keyword alias-id))]
alias-id
[table-id alias-id]

:else
[table-id]))

;; (defn- format-identifier [_ parts]
;; [(str/join \. (map hsql/format-entity parts))])

;; (hsql/register-fn! ::identifier #'format-identifier)
2 changes: 1 addition & 1 deletion src/toucan2/select.clj
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
(merge {:select (or (not-empty columns)
[:*])}
(when model
{:from [[(keyword (model/table-name model))]]})
{:from [(query/honeysql-table-and-alias model)]})
query)))
(dissoc :columns))]
(next-method query-type model parsed-args)))
Expand Down
3 changes: 1 addition & 2 deletions src/toucan2/update.clj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
(:require
[clojure.spec.alpha :as s]
[methodical.core :as m]
[toucan2.model :as model]
[toucan2.pipeline :as pipeline]
[toucan2.query :as query]
[toucan2.util :as u]))
Expand Down Expand Up @@ -40,7 +39,7 @@
{:query-type query-type, :model model, :parsed-args parsed-args})))
(let [parsed-args (assoc parsed-args
:kv-args (merge kv-args query)
:query {:update [(keyword (model/table-name model))]
:query {:update (query/honeysql-table-and-alias model)
:set changes})]
(next-method query-type model parsed-args)))

Expand Down
13 changes: 13 additions & 0 deletions test/toucan2/delete_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,16 @@
(test/with-discarded-table-changes :venues
(is (= 0
(delete/delete! ::test/venues nil))))))

(derive ::venues.namespaced ::test/venues)

(m/defmethod model/model->namespace ::venues.namespaced
[_model]
{::test/venues :venue})

(deftest namespaced-test
(test/with-discarded-table-changes :venues
(is (= 1
(delete/delete! ::venues.namespaced :venue/id 3)))
(is (= nil
(select/select-one [::test/venues :id :name :category] :id 3)))))
28 changes: 28 additions & 0 deletions test/toucan2/insert_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,31 @@
:created-at (LocalDateTime/parse "2017-01-01T00:00")
:updated-at (LocalDateTime/parse "2017-01-01T00:00")})])
(insert! ::test/venues ::named-rows)))))))

(derive ::venues.namespaced ::test/venues)

(m/defmethod model/model->namespace ::venues.namespaced
[_model]
{::test/venues :venue})

(deftest namespaced-test
(doseq [insert! [#'insert/insert!
#'insert/insert-returning-pks!
#'insert/insert-returning-instances!]]
(test/with-discarded-table-changes :venues
(testing insert!
(is (= (condp = insert!
#'insert/insert! 1
#'insert/insert-returning-pks! [4]
#'insert/insert-returning-instances! [(instance/instance
::venues.namespaced
{:venue/name "Grant & Green"
:venue/category "bar"})])
(insert! [::venues.namespaced :venue/name :venue/category]
{:venue/name "Grant & Green", :venue/category "bar"})))
(is (= (instance/instance
::test/venues
{:id 4
:name "Grant & Green"
:category "bar"})
(select/select-one [::test/venues :id :name :category] :id 4)))))))
35 changes: 35 additions & 0 deletions test/toucan2/model_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,38 @@

;;; [[model/default-connectable]] gets tested basically everywhere, because we define it for the models in
;;; [[toucan2.test]] and use it in almost every test namespace

(derive ::venues.namespaced ::test/venues)

(m/defmethod model/model->namespace ::venues.namespaced
[_model]
{::venues.namespaced :venue
::test/categories :category})

(deftest model->namespace-test
(are [model expected] (= expected
(model/model->namespace model))
::venues.namespaced {::venues.namespaced :venue, ::test/categories :category}
:venues nil
nil nil))

(deftest table-name->namespace-test
(are [model expected] (= expected
(model/table-name->namespace model))
::venues.namespaced {"venues" :venue, "category" :category}
:venues nil
nil nil))

(derive ::venues.namespaced.child ::venues.namespaced)

(deftest namespace-test
(are [model expected] (= expected
(model/namespace model))
::venues.namespaced :venue
::venues.namespaced.child :venue
:venues nil
nil nil))

(deftest namespaced-default-primary-keys-test
(is (= [:venue/id]
(model/primary-keys ::venues.namespaced))))
31 changes: 31 additions & 0 deletions test/toucan2/query_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,34 @@
::venues.compound-pk [:in [[4 "BevMo"]]] [:and [:in :id [4]] [:in :name ["BevMo"]]]
::venues.compound-pk [:in [[4 "BevMo"] [5 "BevLess"]]] [:and [:in :id [4 5]] [:in :name ["BevMo" "BevLess"]]]
::venues.compound-pk [:between [4 "BevMo"] [5 "BevLess"]] [:and [:between :id 4 5] [:between :name "BevMo" "BevLess"]])))

(derive ::venues.namespaced ::test/venues)

(m/defmethod model/model->namespace ::venues.namespaced
[_model]
{::test/venues :venue})

(deftest namespaced-toucan-pk-test
(is (= {:select [:*]
:from [[:venues :venue]]
:where [:= :venue/id 1]}
(query/build :toucan.query-type/select.instances
::venues.namespaced
{:kv-args {:toucan/pk 1}, :query {}}))))

(deftest honeysql-table-and-alias-test
(are [model expected] (= expected
(query/honeysql-table-and-alias model))
::test/venues [:venues]
::venues.namespaced [:venues :venue]
"venues" [:venues]))

;; (deftest identitfier-test
;; (testing "Custom Honey SQL identifier clause"
;; (are [identifier quoted? expected] (= expected
;; (hsql/format {:select [:*], :from [[identifier]]}
;; {:quoted quoted?}))
;; [::query/identifier :wow] false ["SELECT * FROM wow"]
;; [::query/identifier :wow] true ["SELECT * FROM \"wow\""]
;; [::query/identifier :table :field] false ["SELECT * FROM table.field"]
;; [::query/identifier :table :field] true ["SELECT * FROM \"table\".\"field\""])))
Loading

0 comments on commit 4d5d93d

Please sign in to comment.