diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c3a37..b40bc45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ The following changes have been committed to the **master** branch since the 1.0 * Added test for using `any(?)` and arrays in PostgreSQL for `IN (?,,,?)` style queries. Added a **Tips & Tricks** section to **Friendly SQL Functions** with database-specific suggestions, that starts with this one. * Improved documentation in several areas. +The following changes have been committed to the **issue-60** branch since the 1.0.6 release: + +* Address #60 by supporting simpler schema entry formats: `:table/column` is equivalent to the old `[:table :column :one]` and `[:table/column]` is equivalent to the old `[:table :column :many]`. The older formats will continue to be supported but should be considered deprecated. + ## Stable Builds * 2019-08-24 -- 1.0.6 diff --git a/doc/datafy-nav-and-schema.md b/doc/datafy-nav-and-schema.md index 0f31be8..c4ec5a5 100644 --- a/doc/datafy-nav-and-schema.md +++ b/doc/datafy-nav-and-schema.md @@ -20,22 +20,24 @@ In addition to `execute!` and `execute-one!`, you can call `next.jdbc.result-set By default, `next.jdbc` assumes that a column named `id` or `_id` is a foreign key into a table called `` with a primary key called `id`. As an example, if you have a table `address` which has columns `id` (the primary key), `name`, `email`, etc, and a table `contact` which has various columns including `addressid`, then if you retrieve a result set based on `contact`, call `datafy` on it and then "drill down" into the columns, when `(nav row :contact/addressid v)` is called (where `v` is the value of that column in that row) `next.jdbc`'s implementation of `nav` will fetch a single row from the `address` table, identified by `id` matching `v`. -You can override this default behavior for any column in any table by providing a `:schema` option that is a hash map whose keys are column names (usually the table-qualified keywords that `next.jdbc` produces by default) and whose values are tuples containing the name of the table to which that column is a foreign key and the name of the key column within that table. These tuples can optionally include a third value which indicates the cardinality of the foreign key relationship: `:one` or `:many`. The default is `:one` and indicates a one-to-one or many-to-one relationship -- `nav`igation will produce a single row. `:many` indicates a one-to-many or many-to-many relationship -- `nav`igation will produce a result set. +You can override this default behavior for any column in any table by providing a `:schema` option that is a hash map whose keys are column names (usually the table-qualified keywords that `next.jdbc` produces by default) and whose values are table-qualified keywords, optionally wrapped in vectors, that identity the name of the table to which that column is a foreign key and the name of the key column within that table. The default behavior in the example above is equivalent to this `:schema` value: ```clojure -{:contact/addressid [:address :id :one]} ; :one is the default and could be omitted +{:contact/addressid :address/id} ; a one-to-one or many-to-one relationship ``` -If you had a table to track the current valid/bouncing status of email addresses, where `email` is the primary key, you could provide automatic navigation into that using: +If you had a table to track the valid/bouncing status of email addresses over time, `:deliverability`, where `email` is the non-unique key, you could provide automatic navigation into that using: ```clojure -{:contact/addressid [:address :id :one] - :address/email [:deliverability :email]} +{:contact/addressid :address/id + :address/email [:deliverability/email]} ; one-to-many or many-to-many ``` -If you use foreign key constraints in your database, you could probably generate this `:schema` data structure automatically from the metadata in your database. +When you indicate a `*-to-many` relationship, by wrapping the foreign table/key in a vector, `next.jdbc`'s implementation of `nav` will fetch a multi-row result set from the target table. + +If you use foreign key constraints in your database, you could probably generate this `:schema` data structure automatically from the metadata in your database. Similarly, if you use a library that depends on an entity relationship map (such as [seql](https://exoscale.github.io/seql/) or [walkable](https://walkable.gitlab.io/)), then you could probably generate this `:schema` data structure from that entity map. ## Behind The Scenes diff --git a/src/next/jdbc/result_set.clj b/src/next/jdbc/result_set.clj index 96f8229..e13f8c6 100644 --- a/src/next/jdbc/result_set.clj +++ b/src/next/jdbc/result_set.clj @@ -620,6 +620,44 @@ (when table [(keyword table) :id]))) +(defn- expand-schema + "Given a (possibly nil) schema entry, return it expanded to a triple of: + + [table fk cardinality] + + Possibly schema entry input formats are: + * [table fk] => cardinality :one + * [table fk cardinality] -- no change + * :table/fk => [:table :fk :one] + * [:table/fk] => [:table :fk :many]" + [k entry] + (when entry + (if-let [mapping + (cond + (keyword? entry) + [(keyword (namespace entry)) (keyword (name entry)) :one] + + (coll? entry) + (let [[table fk cardinality] entry] + (cond (and table fk cardinality) + entry + + (and table fk) + [table fk :one] + + (keyword? table) + [(keyword (namespace table)) (keyword (name table)) :many])))] + + mapping + (throw (ex-info (str "Invalid schema entry for: " (name k)) {:entry entry}))))) + +(comment + (expand-schema :user/statusid nil) + (expand-schema :user/statusid :status/id) + (expand-schema :user/statusid [:status :id]) + (expand-schema :user/email [:deliverability :email :many]) + (expand-schema :user/email [:deliverability/email])) + (defn- navize-row "Given a connectable object, return a function that knows how to turn a row into a `nav`igable object. @@ -642,8 +680,9 @@ (with-meta row {`core-p/nav (fn [coll k v] (try - (let [[table fk cardinality] (or (get-in opts [:schema k]) - (default-schema k))] + (let [[table fk cardinality] + (expand-schema k (or (get-in opts [:schema k]) + (default-schema k)))] (if fk (let [entity-fn (:table-fn opts identity) exec-fn! (if (= :many cardinality) diff --git a/test/next/jdbc/result_set_test.clj b/test/next/jdbc/result_set_test.clj index ffc9cfa..9b127d9 100644 --- a/test/next/jdbc/result_set_test.clj +++ b/test/next/jdbc/result_set_test.clj @@ -30,7 +30,32 @@ ;; check nav produces a single map with the expected key/value data (is (= 1 ((if (postgres?) :fruit/id :FRUIT/ID) object))) (is (= "Apple" ((if (postgres?) :fruit/name :FRUIT/NAME) object)))))) - (testing "custom schema :one" + (testing "custom schema *-to-1" + (let [connectable (ds) + test-row (rs/datafiable-row {:foo/bar 2} connectable + {:schema {:foo/bar :fruit/id}}) + data (d/datafy test-row) + v (get data :foo/bar)] + ;; check datafication is sane + (is (= 2 v)) + (let [object (d/nav data :foo/bar v)] + ;; check nav produces a single map with the expected key/value data + (is (= 2 ((if (postgres?) :fruit/id :FRUIT/ID) object))) + (is (= "Banana" ((if (postgres?) :fruit/name :FRUIT/NAME) object)))))) + (testing "custom schema *-to-many" + (let [connectable (ds) + test-row (rs/datafiable-row {:foo/bar 3} connectable + {:schema {:foo/bar [:fruit/id]}}) + data (d/datafy test-row) + v (get data :foo/bar)] + ;; check datafication is sane + (is (= 3 v)) + (let [object (d/nav data :foo/bar v)] + ;; check nav produces a result set with the expected key/value data + (is (vector? object)) + (is (= 3 ((if (postgres?) :fruit/id :FRUIT/ID) (first object)))) + (is (= "Peach" ((if (postgres?) :fruit/name :FRUIT/NAME) (first object))))))) + (testing "legacy schema tuples" (let [connectable (ds) test-row (rs/datafiable-row {:foo/bar 2} connectable {:schema {:foo/bar [:fruit :id]}}) @@ -41,8 +66,7 @@ (let [object (d/nav data :foo/bar v)] ;; check nav produces a single map with the expected key/value data (is (= 2 ((if (postgres?) :fruit/id :FRUIT/ID) object))) - (is (= "Banana" ((if (postgres?) :fruit/name :FRUIT/NAME) object)))))) - (testing "custom schema :many" + (is (= "Banana" ((if (postgres?) :fruit/name :FRUIT/NAME) object))))) (let [connectable (ds) test-row (rs/datafiable-row {:foo/bar 3} connectable {:schema {:foo/bar [:fruit :id :many]}})