diff --git a/e2e/test/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js index 6078dd9736d77d6b1018d3f075ba31a75401e9d0..59908f41535b6906d6f29830de66d47138289623 100644 --- a/e2e/test/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js @@ -15,7 +15,12 @@ const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; const questionDetails = { query: { "source-table": ORDERS_ID, - expressions: { "CC Date": ["field", ORDERS.CREATED_AT, null] }, + expressions: { + "CC Date": ["field", ORDERS.CREATED_AT, { "base-type": "type/DateTime" }], + }, + "order-by": [ + ["asc", ["field", ORDERS.ID, { "base-type": "type/BigInteger" }]], + ], }, }; @@ -31,7 +36,7 @@ const parameters = [ const dashboardDetails = { parameters }; -describe.skip("issue 17775", () => { +describe("issue 17775", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); @@ -55,6 +60,7 @@ describe.skip("issue 17775", () => { // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText("Column to filter on") + .parent() .parent() .within(() => { cy.findByText("Select…").click(); @@ -70,11 +76,9 @@ describe.skip("issue 17775", () => { it("should be able to apply dashboard filter to a custom column (metabase#17775)", () => { filterWidget().click(); - setQuarterAndYear({ quarter: "Q1", year: "2025" }); - - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("37.65"); + setQuarterAndYear({ quarter: "Q1", year: "2023" }); - cy.findAllByText("February 11, 2025, 9:40 PM").should("have.length", 2); + cy.findAllByText("44.43").should("have.length", 2); + cy.findAllByText("March 26, 2023, 8:45 AM").should("have.length", 2); }); }); diff --git a/e2e/test/scenarios/native-filters/helpers/e2e-date-filter-helpers.js b/e2e/test/scenarios/native-filters/helpers/e2e-date-filter-helpers.js index 5af610f88483d9896b036f89f5f5659fb9369d51..fcd1d834114d3fbb130bbe3822482b13c1fa3a2a 100644 --- a/e2e/test/scenarios/native-filters/helpers/e2e-date-filter-helpers.js +++ b/e2e/test/scenarios/native-filters/helpers/e2e-date-filter-helpers.js @@ -10,10 +10,10 @@ export function setMonthAndYear({ month, year } = {}) { } export function setQuarterAndYear({ quarter, year } = {}) { - cy.findByText(currentYearString).click(); + popover().findByText(currentYearString).click(); - cy.findByText(year).click(); - cy.findByText(quarter).click(); + popover().last().findByText(year).click(); + popover().findByText(quarter).click(); } function setDate(date, container) { diff --git a/src/metabase/driver/common/parameters/dates.clj b/src/metabase/driver/common/parameters/dates.clj index b5c177c25782fc484fad7e7b74869892583ff2da..6b9a94cbc2a1ac3ded90e90830c5f1d92f4826ec 100644 --- a/src/metabase/driver/common/parameters/dates.clj +++ b/src/metabase/driver/common/parameters/dates.clj @@ -157,6 +157,11 @@ [relative-suffix] (= "~" relative-suffix)) +(defn- with-temporal-unit-if-field + [clause unit] + (cond-> clause + (mbql.u/is-clause? :field clause) (mbql.u/with-temporal-unit unit))) + (def ^:private relative-date-string-decoders [{:parser #(= % "today") :range (fn [_ dt] @@ -164,7 +169,7 @@ {:start dt-res, :end dt-res})) :filter (fn [_ field-clause] - [:= (mbql.u/with-temporal-unit field-clause :day) [:relative-datetime :current]])} + [:= (with-temporal-unit-if-field field-clause :day) [:relative-datetime :current]])} {:parser #(= % "yesterday") :range (fn [_ dt] @@ -172,7 +177,7 @@ {:start (t/minus dt-res (t/days 1)) :end (t/minus dt-res (t/days 1))})) :filter (fn [_ field-clause] - [:= (mbql.u/with-temporal-unit field-clause :day) [:relative-datetime -1 :day]])} + [:= (with-temporal-unit-if-field field-clause :day) [:relative-datetime -1 :day]])} ;; Adding a tilde (~) at the end of a past<n><unit>s filter means we should include the current day/etc. ;; e.g. past30days = past 30 days, not including partial data for today ({:include-current false}) @@ -236,7 +241,7 @@ ;; TODO - using `range->filter` so much below seems silly. Why can't we just bucket the field and use `:=` clauses? (defn- range->filter [{:keys [start end]} field-clause] - [:between (mbql.u/with-temporal-unit field-clause :day) (->iso-8601-date start) (->iso-8601-date end)]) + [:between (with-temporal-unit-if-field field-clause :day) (->iso-8601-date start) (->iso-8601-date end)]) (def ^:private short-day->day {"Mon" :monday @@ -302,25 +307,25 @@ {:start date, :end date}) :filter (fn [{:keys [date]} field-clause] (let [iso8601date (->iso-8601-date date)] - [:= (mbql.u/with-temporal-unit field-clause :day) iso8601date]))} + [:= (with-temporal-unit-if-field field-clause :day) iso8601date]))} ;; day range {:parser (regex->parser #"([0-9-T:]+)~([0-9-T:]+)" [:date-1 :date-2]) :range (fn [{:keys [date-1 date-2]} _] {:start date-1, :end date-2}) :filter (fn [{:keys [date-1 date-2]} field-clause] - [:between (mbql.u/with-temporal-unit field-clause :day) (->iso-8601-date date-1) (->iso-8601-date date-2)])} + [:between (with-temporal-unit-if-field field-clause :day) (->iso-8601-date date-1) (->iso-8601-date date-2)])} ;; before day {:parser (regex->parser #"~([0-9-T:]+)" [:date]) :range (fn [{:keys [date]} _] {:end date}) :filter (fn [{:keys [date]} field-clause] - [:< (mbql.u/with-temporal-unit field-clause :day) (->iso-8601-date date)])} + [:< (with-temporal-unit-if-field field-clause :day) (->iso-8601-date date)])} ;; after day {:parser (regex->parser #"([0-9-T:]+)~" [:date]) :range (fn [{:keys [date]} _] {:start date}) :filter (fn [{:keys [date]} field-clause] - [:> (mbql.u/with-temporal-unit field-clause :day) (->iso-8601-date date)])} + [:> (with-temporal-unit-if-field field-clause :day) (->iso-8601-date date)])} ;; exclusions {:parser (regex->parser date-exclude-regex [:unit :exclusions]) :filter (fn [{:keys [unit exclusions]} field-clause] @@ -328,7 +333,7 @@ exclusions (map (partial excluded-datetime unit (t/local-date)) (str/split exclusions #"-"))] (when (and (seq exclusions) (every? some? exclusions)) - (into [:!= (mbql.u/with-temporal-unit field-clause (excluded-temporal-unit unit))] exclusions))))}]) + (into [:!= (with-temporal-unit-if-field field-clause (excluded-temporal-unit unit))] exclusions))))}]) (def ^:private all-date-string-decoders (concat relative-date-string-decoders absolute-date-string-decoders)) diff --git a/src/metabase/models/params.clj b/src/metabase/models/params.clj index 0613e2be8baaa4d31e75030b1d9d313f10192067..191431cca65b6c8d285bd52a53c6a92fdb53859d 100644 --- a/src/metabase/models/params.clj +++ b/src/metabase/models/params.clj @@ -46,16 +46,23 @@ (throw (ex-info (tru ":parameter_mappings must be a sequence of maps with :parameter_id and :type keys") {:parameter_mappings parameter_mappings})))) -(mu/defn unwrap-field-clause :- mbql.s/field - "Unwrap something that contains a `:field` clause, such as a template tag, Also handles unwrapped integers for - legacy compatibility. +(mu/defn unwrap-field-clause :- [:maybe mbql.s/field] + "Unwrap something that contains a `:field` clause, such as a template tag. + Also handles unwrapped integers for legacy compatibility. - (unwrap-field-clause [:field-id 100]) ; -> [:field-id 100]" + (unwrap-field-clause [:field 100 nil]) ; -> [:field 100 nil]" [field-form] (if (integer? field-form) [:field field-form nil] (mbql.u/match-one field-form :field))) +(mu/defn unwrap-field-or-expression-clause :- mbql.s/Field + "Unwrap a `:field` clause or expression clause, such as a template tag. Also handles unwrapped integers for + legacy compatibility." + [field-or-ref-form] + (or (unwrap-field-clause field-or-ref-form) + (mbql.u/match-one field-or-ref-form :expression))) + (defn wrap-field-id-if-needed "Wrap a raw Field ID in a `:field` clause if needed." [field-id-or-form] @@ -101,7 +108,7 @@ [[_ tag] card] (get-in card [:dataset_query :native :template-tags (u/qualified-name tag) :dimension])) -(mu/defn param-target->field-clause :- [:maybe mbql.s/field] +(mu/defn param-target->field-clause :- [:maybe mbql.s/Field] "Parse a Card parameter `target` form, which looks something like `[:dimension [:field-id 100]]`, and return the Field ID it references (if any)." [target card] @@ -109,7 +116,7 @@ (when (mbql.u/is-clause? :dimension target) (let [[_ dimension] target] (try - (unwrap-field-clause + (unwrap-field-or-expression-clause (if (mbql.u/is-clause? :template-tag dimension) (template-tag->field-form dimension card) dimension)) @@ -215,7 +222,7 @@ ;;; | DASHBOARD-SPECIFIC | ;;; +----------------------------------------------------------------------------------------------------------------+ -(mu/defn ^:private dashcards->parameter-mapping-field-clauses :- [:maybe [:set mbql.s/field]] +(mu/defn ^:private dashcards->parameter-mapping-field-clauses :- [:maybe [:set mbql.s/Field]] "Return set of any Fields referenced directly by the Dashboard's `:parameters` (i.e., 'explicit' parameters) by looking at the appropriate `:parameter_mappings` entries for its Dashcards." [dashcards] diff --git a/src/metabase/query_processor/middleware/parameters/mbql.clj b/src/metabase/query_processor/middleware/parameters/mbql.clj index 1258c7d8f68a27fdbb6ef2ec755c594dcca250b7..b4abae1af787bc3ef9924fd66d7fb4df4af5c38b 100644 --- a/src/metabase/query_processor/middleware/parameters/mbql.clj +++ b/src/metabase/query_processor/middleware/parameters/mbql.clj @@ -3,6 +3,8 @@ (:require [metabase.driver.common.parameters.dates :as params.dates] [metabase.driver.common.parameters.operators :as params.ops] + [metabase.lib.convert :as lib.convert] + [metabase.lib.core :as lib] [metabase.lib.metadata.protocols :as lib.metadata.protocols] [metabase.mbql.schema :as mbql.s] [metabase.mbql.util :as mbql.u] @@ -21,21 +23,33 @@ (Double/parseDouble s) (Long/parseLong s))) +(defn- field-type + [field-clause] + (mbql.u/match-one field-clause + [:field (id :guard integer?) _] ((some-fn :effective-type :base-type) + (lib.metadata.protocols/field (qp.store/metadata-provider) id)) + [:field (_ :guard string?) opts] (:base-type opts))) + +(defn- expression-type + [query expression-clause] + (mbql.u/match-one expression-clause + [:expression (expression-name :guard string?)] + (lib/type-of (lib/query (qp.store/metadata-provider) (lib.convert/->pMBQL query)) + (lib.convert/->pMBQL &match)))) + (mu/defn ^:private parse-param-value-for-type "Convert `param-value` to a type appropriate for `param-type`. The frontend always passes parameters in as strings, which is what we want in most cases; for numbers, instead convert the parameters to integers or floating-point numbers." - [param-type param-value field-clause :- mbql.s/field] + [query param-type param-value field-clause :- mbql.s/Field] (cond ;; for `id` or `category` type params look up the base-type of the Field and see if it's a number or not. ;; If it *is* a number then recursively call this function and parse the param value as a number as appropriate. (and (#{:id :category} param-type) - (let [base-type (mbql.u/match-one field-clause - [:field (id :guard integer?) _] ((some-fn :effective-type :base-type) - (lib.metadata.protocols/field (qp.store/metadata-provider) id)) - [:field (_ :guard string?) opts] (:base-type opts))] + (let [base-type (or (field-type field-clause) + (expression-type query field-clause))] (isa? base-type :type/Number))) - (recur :number param-value field-clause) + (recur query :number param-value field-clause) ;; no conversion needed if PARAM-TYPE isn't :number or PARAM-VALUE isn't a string (or (not= param-type :number) @@ -46,7 +60,7 @@ (to-numeric param-value))) (mu/defn ^:private build-filter-clause :- [:maybe mbql.s/Filter] - [{param-type :type, param-value :value, [_ field :as target] :target, :as param}] + [query {param-type :type, param-value :value, [_ field :as target] :target, :as param}] (cond (params.ops/operator? param-type) (params.ops/to-clause param) @@ -54,12 +68,13 @@ (sequential? param-value) (mbql.u/simplify-compound-filter (vec (cons :or (for [value param-value] - (build-filter-clause {:type param-type, :value value, :target target}))))) + (build-filter-clause query {:type param-type, :value value, :target target}))))) ;; single value, date range. Generate appropriate MBQL clause based on date string (params.dates/date-type? param-type) - (params.dates/date-string->filter (parse-param-value-for-type param-type param-value (params/unwrap-field-clause field)) - field) + (params.dates/date-string->filter + (parse-param-value-for-type query param-type param-value (params/unwrap-field-or-expression-clause field)) + field) ;; TODO - We can't tell the difference between a dashboard parameter (convert to an MBQL filter) and a native ;; query template tag parameter without this. There's should be a better, less fragile way to do this. (Not 100% @@ -71,7 +86,7 @@ :else [:= (params/wrap-field-id-if-needed field) - (parse-param-value-for-type param-type param-value (params/unwrap-field-clause field))])) + (parse-param-value-for-type query param-type param-value (params/unwrap-field-or-expression-clause field))])) (defn expand "Expand parameters for MBQL queries in `query` (replacing Dashboard or Card-supplied params with the appropriate @@ -87,6 +102,6 @@ (recur query rest) :else - (let [filter-clause (build-filter-clause (assoc param :value param-value)) + (let [filter-clause (build-filter-clause query (assoc param :value param-value)) query (mbql.u/add-filter-clause query filter-clause)] (recur query rest))))) diff --git a/test/metabase/query_processor/middleware/parameters/mbql_test.clj b/test/metabase/query_processor/middleware/parameters/mbql_test.clj index ac6fc3b8f6fe900bd5abd98cb05790054163d040..d05e6ef4191b4002267216ebd1b1047317f35ea4 100644 --- a/test/metabase/query_processor/middleware/parameters/mbql_test.clj +++ b/test/metabase/query_processor/middleware/parameters/mbql_test.clj @@ -275,14 +275,15 @@ :target $date :value ["2014-06" "2015-06"]}]}))))) -(defn- build-filter-clause [param] +(defn- build-filter-clause [query param] (qp.store/with-metadata-provider (mt/id) - (#'qp.mbql/build-filter-clause param))) + (#'qp.mbql/build-filter-clause query param))) (deftest ^:parallel convert-ids-to-numbers-test (is (= (mt/$ids venues [:= $id 1]) (build-filter-clause + nil (mt/$ids venues {:type :id :target [:dimension $id] diff --git a/test/metabase/query_processor/middleware/parameters_test.clj b/test/metabase/query_processor/middleware/parameters_test.clj index 30d34fde8162574cbfdf769b801132a955b09fb6..56dece06e079df641abdeb3acc3c738ec14da492 100644 --- a/test/metabase/query_processor/middleware/parameters_test.clj +++ b/test/metabase/query_processor/middleware/parameters_test.clj @@ -78,6 +78,27 @@ :parameters [{:name "price", :type :category, :target $price, :value 1}]} :aggregation [[:count]]})))))) +(deftest ^:parallel expand-mbql-source-query-date-expression-param-test + (testing "can we expand MBQL number and date expression params in a source query?" + (is (= (mt/mbql-query users + {:source-query {:source-table (meta/id :users) + :expressions {"date-column" [:field (meta/id :users :last-login) nil] + "number-column" [:field (meta/id :users :id) nil]} + :filter [:and + [:between [:expression "date-column"] "2019-09-29" "2023-09-29"] + [:= [:expression "number-column"] 1]]}}) + (substitute-params + (mt/mbql-query users + {:source-query {:source-table (meta/id :users) + :expressions {"date-column" [:field (meta/id :users :last-login) nil] + "number-column" [:field (meta/id :users :id) nil]} + :parameters [{:type :date/range + :value "2019-09-29~2023-09-29" + :target [:dimension [:expression "date-column"]]} + {:type :category + :value 1 + :target [:dimension [:expression "number-column"]]}]}})))))) + (deftest ^:parallel expand-native-source-query-params-test (testing "can we expand native params if in a source query?" (is (= (mt/mbql-query nil