From e70985e93beab6ed719b30f7d981d2bf5d0301b4 Mon Sep 17 00:00:00 2001 From: Sean Corfield Date: Sun, 7 Mar 2021 19:21:13 -0800 Subject: [PATCH] Fixes #277 by adding join-by --- CHANGELOG.md | 1 + doc/clause-reference.md | 24 ++++++++++++++++++++ src/honey/sql.cljc | 38 ++++++++++++++++++++++++++++++++ src/honey/sql/helpers.cljc | 16 ++++++++++++++ test/honey/sql/helpers_test.cljc | 26 +++++++++++++++++++++- 5 files changed, 104 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db4e4f..b294c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Add tests to confirm #299 does not affect v2. * Confirm the whole of the [nilenso/honeysql-postgres](https://github.com/nilenso/honeysql-postgres) is implemented out-of-the-box (#293). * Reconcile `where` behavior with recent 1.0 changes (porting #283 to v2). + * Fix #277 by adding `:join-by`/`join-by` so that you can have multiple `JOIN`'s in a specific order. * 2.0.0-alpha2 (for early testing) * Since Alpha 1, a lot more documentation has been written and docstrings have been added to most functions in `honey.sql.helpers`. diff --git a/doc/clause-reference.md b/doc/clause-reference.md index 09aaca6..9e52a52 100644 --- a/doc/clause-reference.md +++ b/doc/clause-reference.md @@ -410,6 +410,8 @@ user=> (sql/format {:select [:u.username :s.name] ["SELECT u.username, s.name FROM user AS u INNER JOIN status AS s ON u.statusid = s.id WHERE s.id = ?" 2] ``` +`:join` is shorthand for `:inner-join`. + An alternative to a join condition is a `USING` expression: ```clojure @@ -430,6 +432,28 @@ table name and an alias. > Note: the actual formatting of a `:cross-join` clause is currently identical to the formatting of a `:select` clause. +## join-by + +This is a convenience that allows for an arbitrary sequence of `JOIN` +operations to be performed in a specific order. It accepts a sequence +of join operation name (keyword or symbol) and the clause that join +would take: + +```clojure +user=> (sql/format {:select [:t.ref :pp.code] + :from [[:transaction :t]] + :join-by [:left [[:paypal-tx :pp] + [:using :id]] + :join [[:logtransaction :log] + [:= :t.id :log.id]]] + :where [:= "settled" :pp.status]}) +["SELECT t.ref, pp.code FROM transaction AS t LEFT JOIN paypal_tx AS pp USING (id) INNER JOIN logtransaction AS log ON t.id = log.id WHERE ? = pp.status" "settled"] +``` + +Without `:join-by`, a `:join` would normally be generated before a `:left-join`. +To avoid repetition, `:join-by` allows shorthand versions of the join clauses +using a keyword (or symbol) without the `-join` suffix, as shown in this example. + ## set (MySQL) This is the precedence of the `:set` clause for the MySQL dialect. diff --git a/src/honey/sql.cljc b/src/honey/sql.cljc index dba295f..0fe7e47 100644 --- a/src/honey/sql.cljc +++ b/src/honey/sql.cljc @@ -46,6 +46,7 @@ :select :select-distinct :select-distinct-on :insert-into :update :delete :delete-from :truncate :columns :set :from :using + :join-by :join :left-join :right-join :inner-join :outer-join :full-join :cross-join :where :group-by :having @@ -381,6 +382,42 @@ (partition 2 clauses))] (into [(str/join " " sqls)] params))) +(def ^:private join-by-aliases + "Map of shorthand to longhand join names." + {:join :inner-join + :left :left-join + :right :right-join + :inner :inner-join + :outer :outer-join + :full :full-join + :cross :cross-join}) + +(def ^:private valid-joins + (set (vals join-by-aliases))) + +(defn- format-join-by + "Clauses should be a sequence of join types followed + by their table and condition, so that you can construct + a series of joins in a specific order." + [_ clauses] + (let [joins (partition-by ident? clauses)] + (when-not (even? (count joins)) + (throw (ex-info ":join-by expects a sequence of join clauses" + {:clauses clauses}))) + (let [[sqls params] + (reduce (fn [[sqls params] [[j] [clauses]]] + (let [j' (sym->kw j) + j' (sym->kw (join-by-aliases j' j'))] + (when-not (valid-joins j') + (throw (ex-info (str ":join-by found an invalid join type " + j) + {}))) + (let [[sql' & params'] (format-dsl {j' clauses})] + [(conj sqls sql') (into params params')]))) + [[] []] + (partition 2 joins))] + (into [(str/join " " sqls)] params)))) + (defn- format-on-expr [k e] (if (or (not (sequential? e)) (seq e)) (let [[sql & params] (format-expr e)] @@ -628,6 +665,7 @@ :set #'format-set-exprs :from #'format-selects :using #'format-selects + :join-by #'format-join-by :join #'format-join :left-join #'format-join :right-join #'format-join diff --git a/src/honey/sql/helpers.cljc b/src/honey/sql/helpers.cljc index 6de99c7..0177e50 100644 --- a/src/honey/sql/helpers.cljc +++ b/src/honey/sql/helpers.cljc @@ -393,6 +393,22 @@ [& args] (generic :using args)) +(defn join-by + "Accepts a sequence of join clauses to be generated + in a specific order. + + (-> (select :*) + (from :foo) + (join-by :left :bar [:= :foo.id :bar.id] + :join :quux [:= :bar.qid = :quux.id]) + + This produces a LEFT JOIN followed by an INNER JOIN + even though the 'natural' order for `left-join` and + `join` would be to generate the INNER JOIN first, + followed by the LEFT JOIN." + [& args] + (generic :join-by args)) + (defn join [& args] (generic :join args)) diff --git a/test/honey/sql/helpers_test.cljc b/test/honey/sql/helpers_test.cljc index 320ca19..c59638f 100644 --- a/test/honey/sql/helpers_test.cljc +++ b/test/honey/sql/helpers_test.cljc @@ -9,7 +9,7 @@ :refer [add-column add-index alter-table columns create-table create-view cross-join do-update-set drop-column drop-index drop-table from full-join group-by having insert-into - join left-join limit offset on-conflict order-by + join-by join left-join limit offset on-conflict order-by over partition-by rename-column rename-table returning right-join select select-distinct values where window with with-columns]])) @@ -75,6 +75,30 @@ ;; to enable :lock :dialect :mysql :quoted false})))))) +(deftest join-by-test + (testing "Natural JOIN orders" + (is (= ["SELECT * FROM foo INNER JOIN draq ON f.b = draq.x LEFT JOIN clod AS c ON f.a = c.d RIGHT JOIN bock ON bock.z = c.e FULL JOIN beck ON beck.x = c.y"] + (sql/format {:select [:*] :from [:foo] + :full-join [:beck [:= :beck.x :c.y]] + :right-join [:bock [:= :bock.z :c.e]] + :left-join [[:clod :c] [:= :f.a :c.d]] + :join [:draq [:= :f.b :draq.x]]})))) + (testing "Specific JOIN orders" + (is (= ["SELECT * FROM foo FULL JOIN beck ON beck.x = c.y RIGHT JOIN bock ON bock.z = c.e LEFT JOIN clod AS c ON f.a = c.d INNER JOIN draq ON f.b = draq.x"] + (sql/format {:select [:*] :from [:foo] + :join-by [:full [:beck [:= :beck.x :c.y]] + :right [:bock [:= :bock.z :c.e]] + :left [[:clod :c] [:= :f.a :c.d]] + :join [:draq [:= :f.b :draq.x]]]}))) + (is (= ["SELECT * FROM foo FULL JOIN beck ON beck.x = c.y RIGHT JOIN bock ON bock.z = c.e LEFT JOIN clod AS c ON f.a = c.d INNER JOIN draq ON f.b = draq.x"] + (-> (select :*) + (from :foo) + (join-by :full-join [:beck [:= :beck.x :c.y]] + :right-join [:bock [:= :bock.z :c.e]] + :left-join [[:clod :c] [:= :f.a :c.d]] + :inner-join [:draq [:= :f.b :draq.x]]) + (sql/format)))))) + (deftest test-cast (is (= ["SELECT foo, CAST(bar AS integer)"] (sql/format {:select [:foo [[:cast :bar :integer]]]})))