From 4d31c458a5a87c72e5cf2ea27c58638987f91c20 Mon Sep 17 00:00:00 2001
From: Cam Saul <1455846+camsaul@users.noreply.github.com>
Date: Thu, 28 Apr 2022 12:40:09 -0700
Subject: [PATCH] `mbql.u/add-temporal-unit` should ignore invalid units
 (#22156)

* mbql.u/add-temporal-unit should ignore invalid units rather than erroring

* Test fixes :wrench:
---
 shared/src/metabase/mbql/normalize.cljc       |  4 +-
 shared/src/metabase/mbql/schema.cljc          | 27 +++++++-----
 shared/src/metabase/mbql/util.cljc            | 13 +++++-
 shared/test/metabase/mbql/normalize_test.cljc | 34 +++++++++------
 shared/test/metabase/mbql/schema_test.cljc    | 43 ++++++++++---------
 shared/test/metabase/mbql/util_test.cljc      | 10 +++++
 6 files changed, 83 insertions(+), 48 deletions(-)

diff --git a/shared/src/metabase/mbql/normalize.cljc b/shared/src/metabase/mbql/normalize.cljc
index ffeebe4ed61..154d1bf6bca 100644
--- a/shared/src/metabase/mbql/normalize.cljc
+++ b/shared/src/metabase/mbql/normalize.cljc
@@ -365,7 +365,7 @@
         :else
         x)
       (catch #?(:clj Throwable :cljs js/Error) e
-        (throw (ex-info (i18n/tru "Error normalizing form.")
+        (throw (ex-info (i18n/tru "Error normalizing form: {0}" (ex-message e))
                         {:form x, :path path, :special-fn special-fn}
                         e))))))
 
@@ -589,7 +589,7 @@
          (canonicalize-mbql-clause x)
          (catch #?(:clj Throwable :cljs js/Error) e
            (log/error (i18n/tru "Invalid clause:") x)
-           (throw (ex-info (i18n/tru "Invalid MBQL clause")
+           (throw (ex-info (i18n/tru "Invalid MBQL clause: {0}" (ex-message e))
                            {:clause x}
                            e))))))
    mbql-query))
diff --git a/shared/src/metabase/mbql/schema.cljc b/shared/src/metabase/mbql/schema.cljc
index ee6a166c78c..6ea9c07a8df 100644
--- a/shared/src/metabase/mbql/schema.cljc
+++ b/shared/src/metabase/mbql/schema.cljc
@@ -293,21 +293,28 @@
       validate-bin-width
       validate-num-bins))
 
+(defn valid-temporal-unit-for-base-type?
+  "Whether `temporal-unit` (e.g. `:day`) is valid for the given `base-type` (e.g. `:type/Date`). If either is `nil` this
+  will return truthy. Accepts either map of `field-options` or `base-type` and `temporal-unit` passed separately."
+  ([{:keys [base-type temporal-unit] :as _field-options}]
+   (valid-temporal-unit-for-base-type? base-type temporal-unit))
+
+  ([base-type temporal-unit]
+   (if-let [units (when (core/and temporal-unit base-type)
+                    (condp #(isa? %2 %1) base-type
+                      :type/Date     date-bucketing-units
+                      :type/Time     time-bucketing-units
+                      :type/DateTime datetime-bucketing-units
+                      nil))]
+     (contains? units temporal-unit)
+     true)))
+
 (defn- validate-temporal-unit [schema]
   ;; TODO - consider breaking this out into separate constraints for the three different types so we can generate more
   ;; specific error messages
   (s/constrained
    schema
-   (fn [{:keys [base-type temporal-unit]}]
-     (if-not temporal-unit
-       true
-       (if-let [units (condp #(isa? %2 %1) base-type
-                        :type/Date     date-bucketing-units
-                        :type/Time     time-bucketing-units
-                        :type/DateTime datetime-bucketing-units
-                        nil)]
-         (contains? units temporal-unit)
-         true)))
+   valid-temporal-unit-for-base-type?
    "Invalid :temporal-unit for the specified :base-type."))
 
 (defn- no-binning-options-at-top-level [schema]
diff --git a/shared/src/metabase/mbql/util.cljc b/shared/src/metabase/mbql/util.cljc
index ac10df9ae60..94b68d0bc49 100644
--- a/shared/src/metabase/mbql/util.cljc
+++ b/shared/src/metabase/mbql/util.cljc
@@ -4,10 +4,12 @@
   #?@
   (:clj
    [(:require [clojure.string :as str]
+              [clojure.tools.logging :as log]
               [metabase.mbql.schema :as mbql.s]
               [metabase.mbql.schema.helpers :as schema.helpers]
               [metabase.mbql.util.match :as mbql.match]
               [metabase.shared.util.i18n :as i18n]
+              metabase.util.i18n
               [potemkin :as p]
               [schema.core :as s])]
    :cljs
@@ -661,10 +663,17 @@
 
 (defn with-temporal-unit
   "Set the `:temporal-unit` of a `:field` clause to `unit`."
-  [clause unit]
+  [[_ _ {:keys [base-type]} :as clause] unit]
   ;; it doesn't make sense to call this on an `:expression` or `:aggregation`.
   (assert (is-clause? :field clause))
-  (assoc-field-options clause :temporal-unit unit))
+  (if (or (not base-type)
+          (mbql.s/valid-temporal-unit-for-base-type? base-type unit))
+    (assoc-field-options clause :temporal-unit unit)
+    (do
+      #?(:clj
+         (log/warn (metabase.util.i18n/trs "{0} is not a valid temporal unit for {1}; not adding to clause {2}"
+                                           unit base-type (pr-str clause))))
+      clause)))
 
 (defn remove-namespaced-options
   "Update a `:field`, `:expression` reference, or `:aggregation` reference clause by removing all namespaced keys in the
diff --git a/shared/test/metabase/mbql/normalize_test.cljc b/shared/test/metabase/mbql/normalize_test.cljc
index e171b73b9c8..667179b2a51 100644
--- a/shared/test/metabase/mbql/normalize_test.cljc
+++ b/shared/test/metabase/mbql/normalize_test.cljc
@@ -419,7 +419,7 @@
     "Make sure token normalization works correctly on source queries"
     {{:database 4
       :type     :query
-      :query    {"source_query" {:native         "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10",
+      :query    {"source_query" {:native         "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10"
                                  "template_tags" {:category {:name         "category"
                                                              :display-name "Category"
                                                              :type         "text"
@@ -427,7 +427,7 @@
                                                              :default      "Widget"}}}}}
      {:database 4
       :type     :query
-      :query    {:source-query {:native        "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10",
+      :query    {:source-query {:native        "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10"
                                 :template-tags {"category" {:name         "category"
                                                             :display-name "Category"
                                                             :type         :text
@@ -437,7 +437,7 @@
      {:database 4
       :type     :query
       :query    {"source_query" {"source_table" 1, "aggregation" "rows"}}}
-     {:database 4,
+     {:database 4
       :type     :query
       :query    {:source-query {:source-table 1, :aggregation :rows}}}}))
 
@@ -558,7 +558,7 @@
 
    "expressions should handle datetime arithemtics"
    {{:query {:expressions {:prev_month ["+" ["field-id" 13] ["interval" -1 "month"]]}}}
-    {:query {:expressions {"prev_month" [:+ [:field-id 13] [:interval -1 :month]]}}},
+    {:query {:expressions {"prev_month" [:+ [:field-id 13] [:interval -1 :month]]}}}
 
     {:query {:expressions {:prev_month ["-" ["field-id" 13] ["interval" 1 "month"] ["interval" 1 "day"]]}}}
     {:query {:expressions {"prev_month" [:- [:field-id 13] [:interval 1 :month] [:interval 1 :day]]}}}}
@@ -854,7 +854,7 @@
       :query    {:filter [:and
                           [:segment "gaid:-11"]
                           [:time-interval [:field-id 6851] -365 :day {}]]}}
-     {:database 1,
+     {:database 1
       :type     :query
       :query    {:filter
                  [:and
@@ -943,7 +943,7 @@
                                                             :default      "Widget"}}}}}
      {:database 4
       :type     :query
-      :query    {:source-query {:native        "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10",
+      :query    {:source-query {:native        "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10"
                                 :template-tags {"category" {:name         "category"
                                                             :display-name "Category"
                                                             :type         :text
@@ -1122,7 +1122,7 @@
   (t/testing "make sure source queries get normalized properly!"
     (t/is (= {:database 4
               :type     :query
-              :query    {:source-query {:native        "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10",
+              :query    {:source-query {:native        "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10"
                                         :template-tags {"category" {:name         "category"
                                                                     :display-name "Category"
                                                                     :type         :text
@@ -1131,7 +1131,7 @@
              (mbql.normalize/normalize
               {:database 4
                :type     :query
-               :query    {"source_query" {:native         "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10",
+               :query    {"source_query" {:native         "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10"
                                           "template_tags" {:category {:name         "category"
                                                                       :display-name "Category"
                                                                       :type         "text"
@@ -1402,13 +1402,21 @@
           (t/is (= {:query bad-query}
                    (ex-data e))))
         (t/testing "\nParent exception(s) should be even more specific"
-          (let [cause #?(:clj (some-> ^Throwable e .getCause)
-                         :cljs (ex-cause e))]
+          (let [cause (ex-cause e)]
             (t/is (some? cause))
-            (t/is (= "Error normalizing form."
-                     #?(:clj (.getMessage cause)
-                        :cljs (ex-message cause))))
+            (t/is (re-find #"Error normalizing form:" (ex-message cause)))
             (t/is (= {:form       bad-query
                       :path       []
                       :special-fn nil}
                      (ex-data cause)))))))))
+
+(t/deftest ^:parallel remove-unsuitable-temporal-units-test
+  (t/testing "Ignore unsuitable temporal units (such as bucketing a Date by minute) rather than erroring (#16485)"
+    ;; this query is with legacy MBQL syntax. It's just copied directly from the original issue
+    (let [query {:query {:filter ["<"
+                                  ["datetime-field" ["field-literal" "date_seen" "type/Date"] "minute"]
+                                  "2021-05-01T12:30:00"]}}]
+      (t/is (= {:query {:filter [:<
+                                 [:field "date_seen" {:base-type :type/Date}]
+                                 "2021-05-01T12:30:00"]}}
+               (mbql.normalize/normalize query))))))
diff --git a/shared/test/metabase/mbql/schema_test.cljc b/shared/test/metabase/mbql/schema_test.cljc
index ad467ad96de..481aa526cd4 100644
--- a/shared/test/metabase/mbql/schema_test.cljc
+++ b/shared/test/metabase/mbql/schema_test.cljc
@@ -5,27 +5,28 @@
 
 (t/deftest ^:parallel field-clause-test
   (t/testing "Make sure our schema validates `:field` clauses correctly"
-    (t/are [clause expected] (= expected
-                                (not (s/check mbql.s/field clause)))
-      [:field 1 nil]                                                          true
-      [:field 1 {}]                                                           true
-      [:field 1 {:x true}]                                                    true
-      [:field 1 2]                                                            false
-      [:field "wow" nil]                                                      false
-      [:field "wow" {}]                                                       false
-      [:field "wow" 1]                                                        false
-      [:field "wow" {:base-type :type/Integer}]                               true
-      [:field "wow" {:base-type 100}]                                         false
-      [:field "wow" {:base-type :type/Integer, :temporal-unit :month}]        true
-      [:field "wow" {:base-type :type/Date, :temporal-unit :month}]           true
-      [:field "wow" {:base-type :type/DateTimeWithTZ, :temporal-unit :month}] true
-      [:field "wow" {:base-type :type/Time, :temporal-unit :month}]           false
-      [:field 1 {:binning {:strategy :num-bins}}]                             false
-      [:field 1 {:binning {:strategy :num-bins, :num-bins 1}}]                true
-      [:field 1 {:binning {:strategy :num-bins, :num-bins 1.5}}]              false
-      [:field 1 {:binning {:strategy :num-bins, :num-bins -1}}]               false
-      [:field 1 {:binning {:strategy :default}}]                              true
-      [:field 1 {:binning {:strategy :fake}}]                                 false)))
+    (doseq [[clause expected] {[:field 1 nil]                                                          true
+                               [:field 1 {}]                                                           true
+                               [:field 1 {:x true}]                                                    true
+                               [:field 1 2]                                                            false
+                               [:field "wow" nil]                                                      false
+                               [:field "wow" {}]                                                       false
+                               [:field "wow" 1]                                                        false
+                               [:field "wow" {:base-type :type/Integer}]                               true
+                               [:field "wow" {:base-type 100}]                                         false
+                               [:field "wow" {:base-type :type/Integer, :temporal-unit :month}]        true
+                               [:field "wow" {:base-type :type/Date, :temporal-unit :month}]           true
+                               [:field "wow" {:base-type :type/DateTimeWithTZ, :temporal-unit :month}] true
+                               [:field "wow" {:base-type :type/Time, :temporal-unit :month}]           false
+                               [:field 1 {:binning {:strategy :num-bins}}]                             false
+                               [:field 1 {:binning {:strategy :num-bins, :num-bins 1}}]                true
+                               [:field 1 {:binning {:strategy :num-bins, :num-bins 1.5}}]              false
+                               [:field 1 {:binning {:strategy :num-bins, :num-bins -1}}]               false
+                               [:field 1 {:binning {:strategy :default}}]                              true
+                               [:field 1 {:binning {:strategy :fake}}]                                 false}]
+      (t/testing (pr-str clause)
+        (t/is (= expected
+                 (not (s/check mbql.s/field clause))))))))
 
 (t/deftest ^:parallel validate-template-tag-names-test
   (t/testing "template tags with mismatched keys/`:names` in definition should be disallowed\n"
diff --git a/shared/test/metabase/mbql/util_test.cljc b/shared/test/metabase/mbql/util_test.cljc
index 2de494358d0..255c3ce7028 100644
--- a/shared/test/metabase/mbql/util_test.cljc
+++ b/shared/test/metabase/mbql/util_test.cljc
@@ -792,3 +792,13 @@
     [:aggregation 0]                              [:aggregation 0]
     [:aggregation 0 {::namespaced true}]          [:aggregation 0]
     [:aggregation 0 {::namespaced true, :a 1}]    [:aggregation 0 {:a 1}]))
+
+(t/deftest with-temporal-unit-test
+  (t/is (= [:field 1 {:temporal-unit :day}]
+           (mbql.u/with-temporal-unit [:field 1 nil] :day)))
+  (t/is (= [:field "t" {:base-type :type/Date, :temporal-unit :day}]
+           (mbql.u/with-temporal-unit [:field "t" {:base-type :type/Date}] :day)))
+  (t/testing "Ignore invalid temporal units if `:base-type` is specified (#16485)"
+    ;; `:minute` doesn't make sense for a DATE
+    (t/is (= [:field "t" {:base-type :type/Date}]
+             (mbql.u/with-temporal-unit [:field "t" {:base-type :type/Date}] :minute)))))
-- 
GitLab