From 31d934c527e184b3d401a5775a9bdfbd5b1be723 Mon Sep 17 00:00:00 2001
From: Cam Saul <1455846+camsaul@users.noreply.github.com>
Date: Wed, 19 Jul 2023 15:31:15 -0700
Subject: [PATCH] `with-join-fields` should add join alias to field refs
 (#32491)

---
 src/metabase/lib/field.cljc       |   4 +-
 src/metabase/lib/join.cljc        |  78 +++++++++++++----
 src/metabase/lib/schema.cljc      |   2 +
 src/metabase/lib/schema/join.cljc |  11 +--
 test/metabase/lib/join_test.cljc  | 134 ++++++++++++++++++------------
 test/metabase/lib/test_util.cljc  |  12 +++
 6 files changed, 168 insertions(+), 73 deletions(-)

diff --git a/src/metabase/lib/field.cljc b/src/metabase/lib/field.cljc
index eae94ea0ba9..72293bef577 100644
--- a/src/metabase/lib/field.cljc
+++ b/src/metabase/lib/field.cljc
@@ -98,8 +98,8 @@
                            (resolve-column-name-in-metadata column-name stage-columns))]
       (cond-> column
         previous-stage-number (-> (dissoc :id :table-id
-                                          ::binning ::temporal-unit
-                                          :metabase.lib.join/join-alias)
+                                          ::binning ::temporal-unit)
+                                  (lib.join/with-join-alias nil)
                                   (assoc :name (or (:lib/desired-column-alias column) (:name column)))
                                   (assoc :lib/source :source/previous-stage))))))
 
diff --git a/src/metabase/lib/join.cljc b/src/metabase/lib/join.cljc
index e3d71b058fd..21ed293a489 100644
--- a/src/metabase/lib/join.cljc
+++ b/src/metabase/lib/join.cljc
@@ -48,6 +48,58 @@
    [:ref :mbql.clause/field]
    PartialJoin])
 
+(mu/defn current-join-alias :- [:maybe ::lib.schema.common/non-blank-string]
+  "Get the current join alias associated with something, if it has one."
+  [field-or-join :- [:maybe FieldOrPartialJoin]]
+  (case (lib.dispatch/dispatch-value field-or-join)
+    :dispatch-type/nil nil
+    :field             (:join-alias (lib.options/options field-or-join))
+    :metadata/column   (::join-alias field-or-join)
+    :mbql/join         (:alias field-or-join)))
+
+(declare with-join-alias)
+
+(defn- with-join-alias-update-join-fields
+  "Impl for [[with-join-alias]] for a join: recursively update the `:join-alias` for the `:field` refs inside `:fields`
+  as needed."
+  [join new-alias]
+  (cond-> join
+    (:fields join) (update :fields (fn [fields]
+                                     (if-not (sequential? fields)
+                                       fields
+                                       (mapv (fn [field-ref]
+                                               (with-join-alias field-ref new-alias))
+                                             fields))))))
+
+(defn- with-join-alias-update-join-conditions
+  "Impl for [[with-join-alias]] for a join: recursively update the `:join-alias` for inside the `:conditions` of the
+  join.
+
+  Only updates things that already had a join alias of `old-alias`; does not add alias to things that did not already
+  have one (such as the RHS of a 'typical' join condition). I went back and forth on this behavior, but eventually
+  decided NOT to assume that every join condition is the shape
+
+    [<operator> <lhs-column-from-source> <rhs-column-from-join>]
+
+  This is currently true of normal FE usage, but may not be true everywhere in the future; we don't want to make MLv2
+  impossible to use if you're doing something different like swapping the order of the columns or some sort of more
+  complex condition."
+  [join old-alias new-alias]
+  (if-not old-alias
+    join
+    (mbql.u.match/replace-in join [:conditions]
+      [:field {:join-alias old-alias} _id-or-name]
+      (with-join-alias &match new-alias))))
+
+(defn- with-join-alias-update-join
+  "Impl for [[with-join-alias]] for a join."
+  [join new-alias]
+  (let [old-alias (current-join-alias join)]
+    (-> join
+        (u/assoc-dissoc :alias new-alias)
+        (with-join-alias-update-join-fields new-alias)
+        (with-join-alias-update-join-conditions old-alias new-alias))))
+
 (mu/defn with-join-alias :- FieldOrPartialJoin
   "Add OR REMOVE a specific `join-alias` to `field-or-join`, which is either a `:field`/Field metadata, or a join map.
   Does not recursively update other references (yet; we can add this in the future)."
@@ -62,16 +114,7 @@
     (u/assoc-dissoc field-or-join ::join-alias join-alias)
 
     :mbql/join
-    (u/assoc-dissoc field-or-join :alias join-alias)))
-
-(mu/defn current-join-alias :- [:maybe ::lib.schema.common/non-blank-string]
-  "Get the current join alias associated with something, if it has one."
-  [field-or-join :- [:maybe FieldOrPartialJoin]]
-  (case (lib.dispatch/dispatch-value field-or-join)
-    :dispatch-type/nil nil
-    :field             (:join-alias (lib.options/options field-or-join))
-    :metadata/column   (::join-alias field-or-join)
-    :mbql/join         (:alias field-or-join)))
+    (with-join-alias-update-join field-or-join join-alias)))
 
 (mu/defn resolve-join :- ::lib.schema.join/join
   "Resolve a join with a specific `join-alias`."
@@ -256,10 +299,17 @@
   references."
   [joinable :- PartialJoin
    fields   :- [:maybe [:or [:enum :all :none] [:sequential some?]]]]
-  (u/assoc-dissoc joinable :fields (cond
-                                     (keyword? fields) fields
-                                     (= fields [])     :none
-                                     :else             (not-empty (mapv lib.ref/ref fields)))))
+  (let [fields (cond
+                 (keyword? fields) fields
+                 (= fields [])     :none
+                 :else             (not-empty
+                                    (into []
+                                          (comp (map lib.ref/ref)
+                                                (if-let [current-alias (current-join-alias joinable)]
+                                                  (map #(with-join-alias % current-alias))
+                                                  identity))
+                                          fields)))]
+    (u/assoc-dissoc joinable :fields fields)))
 
 (defn- select-home-column
   [home-cols cond-fields]
diff --git a/src/metabase/lib/schema.cljc b/src/metabase/lib/schema.cljc
index 70743606c7f..d21bdc993cf 100644
--- a/src/metabase/lib/schema.cljc
+++ b/src/metabase/lib/schema.cljc
@@ -52,6 +52,8 @@
 (mr/def ::breakouts
   [:sequential {:min 1} [:ref ::ref/ref]])
 
+;;; TODO -- `:fields` is supposed to be distinct (ignoring UUID), e.g. you can't have `[:field {} 1]` in there
+;;; twice. (#32489)
 (mr/def ::fields
   [:sequential {:min 1} [:ref ::ref/ref]])
 
diff --git a/src/metabase/lib/schema/join.cljc b/src/metabase/lib/schema/join.cljc
index cb486458de3..20ef99d8301 100644
--- a/src/metabase/lib/schema/join.cljc
+++ b/src/metabase/lib/schema/join.cljc
@@ -3,7 +3,6 @@
   (:require
    [metabase.lib.schema.common :as common]
    [metabase.lib.schema.expression :as expression]
-   [metabase.lib.schema.ref :as ref]
    [metabase.shared.util.i18n :as i18n]
    [metabase.util.malli.registry :as mr]))
 
@@ -15,8 +14,9 @@
 ;;;
 ;;; *  `:all`: will include all of the Fields from the joined table or query
 ;;;
-;;; *  a sequence of Field clauses: include only the Fields specified. Valid clauses are the same as the top-level
-;;;    `:fields` clause. This should be non-empty and all elements should be distinct. The normalizer will
+;;; * a sequence of Field clauses: include only the Fields specified. Only `:field` clauses are allowed here!
+;;;    References to expressions or aggregations in the thing we're joining should use column literal (string column
+;;;    name) `:field` references. This should be non-empty and all elements should be distinct. The normalizer will
 ;;;    automatically remove duplicate fields for you, and replace empty clauses with `:none`.
 ;;;
 ;;; Driver implementations: you can ignore this clause. Relevant fields will be added to top-level `:fields` clause
@@ -24,8 +24,9 @@
 (mr/def ::fields
   [:or
    [:enum :all :none]
-   ;; TODO -- Pretty sure fields are supposed to be unique, even excluding `:lib/uuid`
-   [:sequential {:min 1} [:ref ::ref/ref]]])
+   ;; TODO -- `:fields` is supposed to be distinct (ignoring UUID), e.g. you can't have `[:field {} 1]` in there
+   ;; twice. (#32489)
+   [:sequential {:min 1} [:ref :mbql.clause/field]]])
 
 ;;; The name used to alias the joined table or query. This is usually generated automatically and generally looks
 ;;; like `table__via__field`. You can specify this yourself if you need to reference a joined field with a
diff --git a/test/metabase/lib/join_test.cljc b/test/metabase/lib/join_test.cljc
index 549054a4d39..04ab89a1561 100644
--- a/test/metabase/lib/join_test.cljc
+++ b/test/metabase/lib/join_test.cljc
@@ -48,7 +48,7 @@
                                                         :join-alias "Categories"}
                                                        (meta/id :categories :id)]]]
                                        :fields      :all}]}]}
-          (let [q (lib/query meta/metadata-provider (meta/table-metadata :venues))
+          (let [q lib.tu/venues-query
                 j (lib/query meta/metadata-provider (meta/table-metadata :categories))]
             (lib/join q (lib/join-clause j [{:lib/type :lib/external-op
                                              :operator :=
@@ -220,7 +220,7 @@
            (lib.join/joined-thing query join)))))
 
 (deftest ^:parallel joins-source-and-desired-aliases-test
-  (let [query (-> (lib/query meta/metadata-provider (meta/table-metadata :venues))
+  (let [query (-> lib.tu/venues-query
                   (lib/join (-> (lib/join-clause
                                  (meta/table-metadata :categories)
                                  [(lib/=
@@ -386,56 +386,86 @@
            (map (partial lib.metadata.calculation/display-info query)
                 (lib/available-join-strategies query))))))
 
-(deftest ^:parallel with-join-fields-test
-  (doseq [[message {:keys [input expected]}] {:all
-                                              {:input :all, :expected {:fields :all}}
-
-                                              :none
-                                              {:input :none, :expected {:fields :none}}
-
-                                              "(with-join-fields ... []) should set :fields to :none"
-                                              {:input [], :expected {:fields :none}}
+(deftest ^:parallel with-join-alias-update-fields-test
+  (testing "with-join-alias should update the alias of columns in :fields"
+    (let [query  lib.tu/query-with-join-with-explicit-fields
+          [join] (lib/joins query)]
+      (is (=? {:alias  "Cat"
+               :fields [[:field {:join-alias "Cat"} (meta/id :categories :name)]]}
+              join))
+      (let [join' (lib/with-join-alias join "New Alias")]
+        (is (=? {:alias  "New Alias"
+                 :fields [[:field {:join-alias "New Alias"} (meta/id :categories :name)]]}
+                join'))))))
+
+(deftest ^:parallel with-join-alias-update-condition-rhs-test
+  (testing "with-join-alias should update the alias of the RHS column(s) in the condition(s)"
+    (let [query  lib.tu/query-with-join
+          [join] (lib/joins query)]
+      (is (=? {:conditions [[:= {}
+                             [:field {} (meta/id :venues :category-id)]
+                             [:field {:join-alias "Cat"} (meta/id :categories :id)]]]
+               :alias      "Cat"}
+              join))
+      (let [join' (lib/with-join-alias join "New Alias")]
+        (is (=? {:conditions [[:= {}
+                               [:field {} (meta/id :venues :category-id)]
+                               [:field {:join-alias "New Alias"} (meta/id :categories :id)]]]
+                 :alias      "New Alias"}
+                join'))))))
+
+(defn- test-with-join-fields [input expected]
+  (testing (pr-str (list 'with-join-fields 'query input))
+    (let [query (-> lib.tu/venues-query
+                    (lib/join (-> (lib/join-clause
+                                   (meta/table-metadata :categories)
+                                   [(lib/=
+                                     (meta/field-metadata :venues :category-id)
+                                     (lib/with-join-alias (meta/field-metadata :categories :id) "Cat"))])
+                                  (lib/with-join-alias "Cat")
+                                  (lib/with-join-fields input))))]
+      (is (=? {:stages [{:joins [(merge
+                                  {:alias      "Cat"
+                                   :conditions [[:= {}
+                                                 [:field {} (meta/id :venues :category-id)]
+                                                 [:field {:join-alias "Cat"} (meta/id :categories :id)]]]}
+                                  expected)]}]}
+              query))
+      (let [[join] (lib/joins query)]
+        (is (some? join))
+        (is (= (:fields expected)
+               (lib/join-fields join)))))))
 
-                                              nil
-                                              {:input nil, :expected nil}
-
-                                              "explicit :fields"
-                                              (let [categories-id [:field {:lib/uuid   (str (random-uuid))
-                                                                           :join-alias "Cat"}
-                                                                   (meta/id :categories :id)]]
-                                                {:input    [categories-id]
-                                                 :expected {:fields [categories-id]}})}]
-    (testing message
-      (let [query (-> (lib/query meta/metadata-provider (meta/table-metadata :venues))
-                      (lib/join (-> (lib/join-clause
-                                     (meta/table-metadata :categories)
-                                     [(lib/=
-                                       (meta/field-metadata :venues :category-id)
-                                       (lib/with-join-alias (meta/field-metadata :categories :id) "Cat"))])
-                                    (lib/with-join-alias "Cat")
-                                    (lib/with-join-fields input))))]
-        (is (=? {:stages [{:joins [(merge
-                                    {:alias      "Cat"
-                                     :conditions [[:= {}
-                                                   [:field {} (meta/id :venues :category-id)]
-                                                   [:field {:join-alias "Cat"} (meta/id :categories :id)]]]}
-                                    expected)]}]}
-                query))
-        (let [[join] (lib/joins query)]
-          (is (some? join))
-          (is (= (:fields expected)
-                 (lib/join-fields join))))))))
-
-(def ^:private query-with-join-with-fields
-  "A query against `VENUES` joining `CATEGORIES` with `:fields` set to return only `NAME`."
-  (-> lib.tu/venues-query
-      (lib/join (-> (lib/join-clause
-                     (meta/table-metadata :categories)
-                     [(lib/=
-                       (meta/field-metadata :venues :category-id)
-                       (lib/with-join-alias (meta/field-metadata :categories :id) "Cat"))])
-                    (lib/with-join-alias "Cat")
-                    (lib/with-join-fields [(lib/with-join-alias (meta/field-metadata :categories :name) "Cat")])))))
+(deftest ^:parallel with-join-fields-test
+  (doseq [{:keys [input expected]}
+          [{:input :all, :expected {:fields :all}}
+           {:input :none, :expected {:fields :none}}
+           ;; (with-join-fields ... []) should set :fields to :none
+           {:input [], :expected {:fields :none}}
+           {:input nil, :expected nil}]]
+    (test-with-join-fields input expected)))
+
+(deftest ^:parallel with-join-fields-explicit-fields-test
+  (let [categories-id [:field {:lib/uuid   (str (random-uuid))
+                               :join-alias "Cat"}
+                       (meta/id :categories :id)]]
+    (test-with-join-fields
+     [categories-id]
+     {:fields [categories-id]})))
+
+(deftest ^:parallel with-join-fields-update-join-aliases-test
+  (testing "explicit :fields should change join alias for fields that have a different alias (#32437)"
+    (let [categories-id [:field {:lib/uuid (str (random-uuid))} (meta/id :categories :id)]]
+      (test-with-join-fields
+       [(lib/with-join-alias categories-id "Hat")]
+       {:fields [(lib/with-join-alias categories-id "Cat")]}))))
+
+(deftest ^:parallel with-join-fields-add-missing-aliases-test
+  (testing "explicit :fields should add join alias to fields missing it (#32437)"
+    (let [categories-id [:field {:lib/uuid (str (random-uuid))} (meta/id :categories :id)]]
+      (test-with-join-fields
+       [categories-id]
+       {:fields [(lib/with-join-alias categories-id "Cat")]}))))
 
 (deftest ^:parallel join-condition-lhs-columns-test
   (let [query lib.tu/venues-query]
@@ -452,7 +482,7 @@
 
 (deftest ^:parallel join-condition-lhs-columns-with-previous-join-test
   (testing "Include columns from previous join(s)"
-    (let [query query-with-join-with-fields]
+    (let [query lib.tu/query-with-join-with-explicit-fields]
       (doseq [rhs [nil (lib/with-join-alias (lib.metadata/field query (meta/id :users :id)) "User")]]
         (testing (str "rhs = " (pr-str rhs))
           (is (=? [{:lib/desired-column-alias "ID"}
diff --git a/test/metabase/lib/test_util.cljc b/test/metabase/lib/test_util.cljc
index 55a0268bff4..607387c6126 100644
--- a/test/metabase/lib/test_util.cljc
+++ b/test/metabase/lib/test_util.cljc
@@ -186,6 +186,18 @@
   "A query against `VENUES` with an explicit join against `CATEGORIES`."
   (add-joins venues-query "Cat"))
 
+(def query-with-join-with-explicit-fields
+  "A query against `VENUES` with an explicit join against `CATEGORIES`, that includes explicit `:fields` including just
+  `CATEGORIES.NAME`."
+  (-> venues-query
+      (lib/join (-> (lib/join-clause (meta/table-metadata :categories))
+                    (lib/with-join-conditions [(lib/= (meta/field-metadata :venues :category-id)
+                                                      (-> (meta/field-metadata :categories :id)
+                                                          (lib/with-join-alias "Cat")))])
+                    (lib/with-join-alias "Cat")
+                    (lib/with-join-fields [(-> (meta/field-metadata :categories :name)
+                                               (lib/with-join-alias "Cat"))])))))
+
 (def query-with-expression
   "A query with an expression."
   (-> venues-query
-- 
GitLab