diff --git a/src/metabase/lib/schema/common.cljc b/src/metabase/lib/schema/common.cljc index 5388a007ebd82109e545e1d7747a2f3ffbe27caa..92505670e0d26f4cf956c805bd88b6e7d9d7b034 100644 --- a/src/metabase/lib/schema/common.cljc +++ b/src/metabase/lib/schema/common.cljc @@ -10,7 +10,9 @@ (mr/def ::non-blank-string [:and [:string {:min 1}] - [:fn (complement str/blank?)]]) + [:fn + {:error/message "non-blank string"} + (complement str/blank?)]]) ;;; Schema representing an integer than must also be greater than or equal to zero. (mr/def ::int-greater-than-or-equal-to-zero diff --git a/src/metabase/lib/schema/expression/temporal.cljc b/src/metabase/lib/schema/expression/temporal.cljc index 76129a5539fc2f21f4a015bd3e25eded4bb94d94..92e358cd84a9b9947c452903c6e6f8add63ce730 100644 --- a/src/metabase/lib/schema/expression/temporal.cljc +++ b/src/metabase/lib/schema/expression/temporal.cljc @@ -7,7 +7,19 @@ [metabase.lib.schema.literal :as literal] [metabase.lib.schema.mbql-clause :as mbql-clause] [metabase.lib.schema.temporal-bucketing :as temporal-bucketing] - [metabase.util.malli.registry :as mr])) + [metabase.util.malli.registry :as mr]) + #?@ + (:clj + [(:import + (java.time ZoneId))] + :cljs + [(:require + ["moment" :as moment] + ["moment-timezone" :as mtz])])) + +#?(:cljs + ;; so the moment-timezone stuff gets loaded + (comment mtz/keep-me)) (mbql-clause/define-tuple-mbql-clause :interval :- :type/Interval :int @@ -34,19 +46,35 @@ :get-day :get-day-of-week :get-month :get-quarter :get-year}] (mbql-clause/define-tuple-mbql-clause temporal-extract-op :- :type/Integer - #_:datetime [:schema [:ref ::expression/temporal]])) - -(mbql-clause/define-tuple-mbql-clause :get-week :- :type/Integer - #_:datetime [:schema [:ref ::expression/temporal]] - ;; TODO should this be in the options map? - #_:mode [:maybe [:enum :iso :us :instance]]) - -(mbql-clause/define-tuple-mbql-clause :convert-timezone - #_:datetime [:schema [:ref ::expression/temporal]] - ;; TODO could be better specified - perhaps with a build time macro to inline the timezones? - ;; NOT expressions? - #_:target [:string] - #_:source [:maybe [:string]]) + #_:datetime [:schema [:ref ::expression/temporal]])) + +(mr/def ::get-week-mode + [:enum :iso :us :instance]) + +(mbql-clause/define-catn-mbql-clause :get-week :- :type/Integer + [:datetime [:schema [:ref ::expression/temporal]]] + ;; TODO : the mode should probably go in the options map in modern MBQL rather than have it be a separate positional + ;; argument. But we can't refactor everything in one go, so that will have to be a future refactor. + [:mode [:? [:schema [:ref ::get-week-mode]]]]) + +(mr/def ::timezone-id + [:and + + ::common/non-blank-string + (into [:enum + {:error/message "valid timezone ID" + :error/fn (fn [{:keys [value]} _] + (str "invalid timezone ID: " (pr-str value)))}] + (sort + #?( ;; 600 timezones on java 17 + :clj (ZoneId/getAvailableZoneIds) + ;; 596 timezones on moment-timezone 0.5.38 + :cljs (.names (.-tz moment)))))]) + +(mbql-clause/define-catn-mbql-clause :convert-timezone + [:datetime [:schema [:ref ::expression/temporal]]] + [:target [:schema [:ref ::timezone-id]]] + [:source [:? [:schema [:ref ::timezone-id]]]]) (lib.hierarchy/derive :convert-timezone :lib.type-of/type-is-type-of-first-arg) diff --git a/test/metabase/lib/schema/expression/temporal_test.cljc b/test/metabase/lib/schema/expression/temporal_test.cljc index 2b58ecc2728ca4134f489cf635aeec88b302087e..16072df9d1e93e2db4d2a4e4b302f771f7a13c33 100644 --- a/test/metabase/lib/schema/expression/temporal_test.cljc +++ b/test/metabase/lib/schema/expression/temporal_test.cljc @@ -4,7 +4,8 @@ [malli.core :as mc] [malli.error :as me] [metabase.lib.schema] - [metabase.lib.schema.expression :as expression])) + [metabase.lib.schema.expression :as expression] + [metabase.lib.schema.expression.temporal :as temporal])) (comment metabase.lib.schema/keep-me) @@ -68,3 +69,64 @@ [:relative-datetime {:lib/uuid "00000000-0000-0000-0000-000000000000"} :current :day] [:relative-datetime {:lib/uuid "00000000-0000-0000-0000-000000000000"} :current :minute] [:relative-datetime {:lib/uuid "00000000-0000-0000-0000-000000000000"} :current])) + +(deftest ^:parallel timezone-id-test + (are [input error] (= error + (me/humanize (mc/explain ::temporal/timezone-id input))) + "US/Pacific" nil + "US/Specific" ["invalid timezone ID: \"US/Specific\""] + "" ["should be at least 1 characters" "non-blank string" "invalid timezone ID: \"\""] + " " ["non-blank string" "invalid timezone ID: \" \""] + nil ["should be a string" "non-blank string" "invalid timezone ID: nil"])) + +(deftest ^:parallel convert-timezone-test + (are [clause error] (= error + (me/humanize (mc/explain :mbql.clause/convert-timezone clause))) + ;; with both target and source timezone + [:convert-timezone + {:lib/uuid "00000000-0000-0000-0000-000000000000"} + [:field {:lib/uuid "00000000-0000-0000-0000-000000000000"} 1] + "Asia/Seoul" + "US/Pacific"] + nil + + ;; with just the target timezone + [:convert-timezone + {:lib/uuid "00000000-0000-0000-0000-000000000000"} + [:field {:lib/uuid "00000000-0000-0000-0000-000000000000"} 1] + "Asia/Seoul"] + nil + + ;; source cannot be nil + [:convert-timezone + {:lib/uuid "00000000-0000-0000-0000-000000000000"} + [:field {:lib/uuid "00000000-0000-0000-0000-000000000000"} 1] + "Asia/Seoul" + nil] + [nil nil nil nil ["should be a string" "non-blank string" "invalid timezone ID: nil" "Valid :convert-timezone clause"]] + + ;; invalid timezone ID + [:convert-timezone + {:lib/uuid "00000000-0000-0000-0000-000000000000"} + [:field {:lib/uuid "00000000-0000-0000-0000-000000000000"} 1] + "US/Specific"] + [nil nil nil ["invalid timezone ID: \"US/Specific\""]])) + +(deftest ^:parallel get-week-test + (are [clause error] (= error + (me/humanize (mc/explain :mbql.clause/get-week clause))) + ;; without mode + [:get-week {:lib/uuid "00000000-0000-0000-0000-000000000000"} "2023-05-25"] + nil + + ;; with mode + [:get-week {:lib/uuid "00000000-0000-0000-0000-000000000000"} "2023-05-25" :iso] + nil + + ;; invalid mode + [:get-week {:lib/uuid "00000000-0000-0000-0000-000000000000"} "2023-05-25" :isolation] + [nil nil nil ["should be either :iso, :us or :instance" "Valid :get-week clause"]] + + ;; mode is not allowed to be nil + [:get-week {:lib/uuid "00000000-0000-0000-0000-000000000000"} "2023-05-25" nil] + [nil nil nil ["should be either :iso, :us or :instance" "Valid :get-week clause"]])) diff --git a/test/metabase/query_processor_test/test_mlv2.clj b/test/metabase/query_processor_test/test_mlv2.clj index 47d37c59b7059c6fc2c735dab9337f41a3a57be9..c00bb64bc3471036cb0d54bf12be8d5fb53dcab3 100644 --- a/test/metabase/query_processor_test/test_mlv2.clj +++ b/test/metabase/query_processor_test/test_mlv2.clj @@ -29,16 +29,6 @@ place, [[metabase.models.query.permissions-test/invalid-queries-test]]." false) -(defn- skip-conversion-tests? - "Whether to skip conversion tests against a `legacy-query`." - [legacy-query] - (or - *skip-conversion-tests* - ;; #29958: `:convert-timezone` with 2 args is broken - (mbql.u/match-one legacy-query - [:convert-timezone _expr _source-timezone] - "#29958"))) - (defn- skip-metadata-calculation-tests? [legacy-query] (or ;; #29907: wrong column name for joined columns in `:breakout` @@ -62,7 +52,7 @@ (defn- test-mlv2-metadata [original-query _qp-metadata] {:pre [(map? original-query)]} - (when-not (or (skip-conversion-tests? original-query) + (when-not (or *skip-conversion-tests* (skip-metadata-calculation-tests? original-query)) (do-with-legacy-query-testing-context original-query @@ -82,7 +72,7 @@ (is (any? mlv2-metadata))))))))))) (defn- test-mlv2-conversion [query] - (when-not (skip-conversion-tests? query) + (when-not *skip-conversion-tests* (do-with-legacy-query-testing-context query (^:once fn* []