From c6925a889053f619ccb15ddd612731d8bdd398eb Mon Sep 17 00:00:00 2001 From: Ngoc Khuat <qn.khuat@gmail.com> Date: Fri, 23 Dec 2022 16:54:15 +0700 Subject: [PATCH] Dropdown sources using card can select column (#27343) Co-authored-by: Alexander Polyankin <alexander.polyankin@metabase.com> --- src/metabase/api/dashboard.clj | 28 +++- src/metabase/models/parameter_card.clj | 43 ------ src/metabase/models/params/card_values.clj | 54 ++++++++ src/metabase/util/schema.clj | 19 ++- test/metabase/api/dashboard_test.clj | 124 +++++++++--------- .../models/params/card_values_test.clj | 118 +++++++++++++++++ test/metabase/test.clj | 1 + 7 files changed, 278 insertions(+), 109 deletions(-) create mode 100644 src/metabase/models/params/card_values.clj create mode 100644 test/metabase/models/params/card_values_test.clj diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 935f75638f9..1a563cd7d66 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -3,6 +3,7 @@ (:require [cheshire.core :as json] [clojure.set :as set] + [clojure.string :as str] [clojure.tools.logging :as log] [compojure.core :refer [DELETE GET POST PUT]] [medley.core :as m] @@ -22,8 +23,8 @@ [metabase.models.dashboard-card :as dashboard-card :refer [DashboardCard]] [metabase.models.field :refer [Field]] [metabase.models.interface :as mi] - [metabase.models.parameter-card :as parameter-card] [metabase.models.params :as params] + [metabase.models.params.card-values :as params.card-values] [metabase.models.params.chain-filter :as chain-filter] [metabase.models.query :as query :refer [Query]] [metabase.models.query.permissions :as query-perms] @@ -36,6 +37,7 @@ [metabase.query-processor.pivot :as qp.pivot] [metabase.query-processor.util :as qp.util] [metabase.related :as related] + [metabase.search.util :as search] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.schema :as su] @@ -734,14 +736,32 @@ (api/throw-403 e) (throw e))))))) +(defn- query-matches + "Filter the values according to the `search-term`. + + Values could have 2 shapes + - [value1, value2] + - [[value1, label1], [value2, label2]] - we search using label in this case" + [query values] + (let [normalized-query (search/normalize query)] + (filter #(str/includes? (search/normalize (if (string? %) + % + ;; search by label + (second %))) + normalized-query) values))) + (defn- static-parameter-values [{values-source-options :values_source_config :as _param} query] (when-let [values (:values values-source-options)] {:values (if query - (parameter-card/query-matches query values) + (query-matches query values) values) :has_more_values false})) +(defn- card-parameter-values + [{config :values_source_config :as _param} query] + (params.card-values/values-from-card (:card_id config) (:value_field config) query)) + (s/defn param-values "Fetch values for a parameter. @@ -764,8 +784,8 @@ :status-code 400}))) (case (:values_source_type param) "static-list" (static-parameter-values param query) - "card" (parameter-card/values-for-dashboard dashboard param-key query) - (chain-filter dashboard param-key constraint-param-key->value query))))) + "card" (card-parameter-values param query) + nil (chain-filter dashboard param-key constraint-param-key->value query))))) (api/defendpoint GET "/:id/params/:param-key/values" "Fetch possible values of the parameter whose ID is `:param-key`. If the values come directly from a query, optionally diff --git a/src/metabase/models/parameter_card.clj b/src/metabase/models/parameter_card.clj index 8527b9d7842..23161947171 100644 --- a/src/metabase/models/parameter_card.clj +++ b/src/metabase/models/parameter_card.clj @@ -1,8 +1,5 @@ (ns metabase.models.parameter-card (:require - [clojure.string :as str] - [metabase.query-processor :as qp] - [metabase.search.util :as search] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [toucan.db :as db] @@ -72,43 +69,3 @@ upsertable-parameters (filter upsertable? parameters)] (upsert-for-dashboard! dashboard-id upsertable-parameters) (delete-all-for-parameterized-object! "dashboard" dashboard-id (map :id upsertable-parameters)))) - -;;; ----------------------------------------------- param values ----------------------------------------------- - -(defn query-matches - "Filter the values according to the `search-term`. - - Values could have 2 shapes - - [value1, value2] - - [[value1, label1], [value2, label2]] - we search using label in this case" - [search-term values] - (if (str/blank? search-term) - values - (let [normalized-search-term (search/normalize search-term)] - (filter #(str/includes? (search/normalize (if (string? %) - % - ;; search by label - (second %))) - normalized-search-term) values)))) - -(defn- query-for-dashboard - [{dashboard-id :id} param-key] - (->> (db/query {:select [:dataset_query] - :from [:report_card] - :join [:parameter_card [:= :report_card.id :parameter_card.card_id]] - :where [:and - [:= :parameterized_object_id dashboard-id] - [:= :parameterized_object_type "dashboard"] - [:= :parameter_id param-key]]}) - (db/do-post-select 'Card) - first - :dataset_query)) - -(defn values-for-dashboard - "Returns a map with the filter `:values` associated with the dashboard." - [dashboard param-key search-term] - {:values - (->> (qp/process-query (query-for-dashboard dashboard param-key) {:rff (constantly conj)}) - (map first) - (query-matches search-term)) - :has_more_values false}) ;; TODO: this should be more clever diff --git a/src/metabase/models/params/card_values.clj b/src/metabase/models/params/card_values.clj new file mode 100644 index 00000000000..487643a12a1 --- /dev/null +++ b/src/metabase/models/params/card_values.clj @@ -0,0 +1,54 @@ +(ns metabase.models.params.card-values + "Code related to getting values for a parameter where its source values is a card." + (:require + [clojure.string :as str] + [metabase.models :refer [Card]] + [metabase.query-processor :as qp] + [metabase.util.schema :as su] + [schema.core :as s] + [toucan.db :as db])) + +(def ^:dynamic *max-rows* + "Maximum number of rows returned when running a card. + It's 2000 because this is the limit we use when viewing a question. + Maybe we should lower it for the sake of display a parameter dropdown." + 2000) + +(defn- values-from-card-query + [card-id value-field query] + (let [card (db/select-one Card :id card-id)] + {:database (:database_id card) + :type :query + :query (merge + {:source-table (format "card__%d" card-id) + :fields [value-field] + :limit *max-rows*} + (when query + {:filter [:contains [:lower value-field] (str/lower-case query)]})) + :middleware {:disable-remaps? true}})) + +(s/defn values-from-card + "Get values a field from a card. + + (values-from-card 1 [:field \"name\" nil] \"red\") + ;; will execute a mbql that looks like + ;; {:source-table (format \"card__%d\" card-id) + ;; :fields [value-field] + ;; :filter [:contains [:lower value-field] \"red\"] + ;; :limit *max-rows*} + => + {:values [\"Red Medicine\"] + :has_more_values false} + " + ([card-id value-field-ref] + (values-from-card card-id value-field-ref nil)) + + ([card-id :- su/IntGreaterThanZero + value-field :- su/Field + query :- (s/maybe su/NonBlankString)] + (let [mbql-query (values-from-card-query card-id value-field query) + query-limit (get-in mbql-query [:query :limit]) + result (qp/process-query mbql-query)] + {:values (map first (get-in result [:data :rows])) + ;; if the row_count returned = the limit we specified, then it's probably has more than that + :has_more_values (= (:row_count result) query-limit)}))) diff --git a/src/metabase/util/schema.clj b/src/metabase/util/schema.clj index 912fda514b8..0624e9e03c6 100644 --- a/src/metabase/util/schema.clj +++ b/src/metabase/util/schema.clj @@ -5,6 +5,8 @@ [clojure.string :as str] [clojure.walk :as walk] [medley.core :as m] + [metabase.mbql.normalize :as mbql.normalize] + [metabase.mbql.schema :as mbql.s] [metabase.types :as types] [metabase.util :as u] [metabase.util.date-2 :as u.date] @@ -288,6 +290,13 @@ (deferred-tru "Valid field semantic or relation type (keyword or string)")) (deferred-tru "value must be a valid field semantic or relation type (keyword or string)."))) +(def Field + "Schema for a valid Field for API usage." + (with-api-error-message (s/pred + (comp (complement (s/checker mbql.s/Field)) + mbql.normalize/normalize-tokens)) + (deferred-tru "value must an array with :field id-or-name and an options map"))) + (def CoercionStrategyKeywordOrString "Like `CoercionStrategy` but accepts either a keyword or string." (with-api-error-message (s/pred #(isa? (keyword %) :Coercion/*) (deferred-tru "Valid coercion strategy")) @@ -364,8 +373,14 @@ (def ValuesSourceConfig "Schema for valid source_options within a Parameter" ;; TODO: This should be tighter - {(s/optional-key :values) (s/cond-pre [s/Any]) - (s/optional-key :card_id) IntGreaterThanZero}) + {;; for source_type = 'static-list' + (s/optional-key :values) (s/cond-pre [s/Any]) + + ;; for source_type = 'card' + (s/optional-key :card_id) IntGreaterThanZero + (s/optional-key :value_field) Field + ;; label_field is optional + (s/optional-key :label_field) Field}) (def Parameter "Schema for a valid Parameter. diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index 8e24bdf606a..0f7fc4542bd 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -2061,31 +2061,35 @@ (deftest parameter-values-from-card-test ;; TODO add permissions tests - (mt/with-temp* [Card [{card-id :id} {:database_id (mt/id) - :table_id (mt/id :venues) - :dataset_query (mt/native-query {:query "SELECT name FROM venues WHERE name LIKE '%Bar%'"})}] - Dashboard [{dashboard-id :id} {:parameters [{:id "abc" - :type "category" - :name "CATEGORY" - :values_source_type "card" - :values_source_config {:card_id card-id}}]}]] + (mt/with-temp* + [Card [{card-id :id} + (merge (mt/card-with-source-metadata-for-query (mt/mbql-query venues {:limit 5})) + {:database_id (mt/id) + :table_id (mt/id :venues)})] + Dashboard [{dashboard-id :id} + {:parameters [{:id "abc" + :type "category" + :name "CATEGORY" + :values_source_type "card" + :values_source_config {:card_id card-id + :value_field (mt/$ids $venues.name)}}]}]] + (testing "It uses the results of the card's query execution" (let-url [url (chain-filter-values-url dashboard-id "abc")] - (is (= {:values ["The Misfit Restaurant + Bar" - "My Brother's Bar-B-Q" - "Two Sisters Bar & Books" - "Tanoshi Sushi & Sake Bar" - "Barney's Beanery"] + (is (= {:values ["Red Medicine" + "Stout Burgers & Beers" + "The Apple Pan" + "Wurstküche" + "Brite Spot Family Restaurant"] :has_more_values false} (mt/user-http-request :rasta :get 200 url))))) (testing "it only returns search matches" - (let-url [url (chain-filter-search-url dashboard-id "abc" "sushi")] - (is (= {:values ["Tanoshi Sushi & Sake Bar"] + (let-url [url (chain-filter-search-url dashboard-id "abc" "red")] + (is (= {:values ["Red Medicine"] :has_more_values false} (mt/user-http-request :rasta :get 200 url))))))) - (deftest valid-filter-fields-test (testing "GET /api/dashboard/params/valid-filter-fields" (letfn [(url [filtered filtering] @@ -2188,55 +2192,55 @@ :type :native :native {:query "delete from users;"}}}]] (is (= "Write queries are only executable via the Actions API." - (:message (mt/user-http-request :rasta :post 405 (url :card-id (:id card)))))))))))) + (:message (mt/user-http-request :rasta :post 405 (url :card-id (:id card))))))))))))) - ;; see also [[metabase.query-processor.dashboard-test]] - (deftest dashboard-card-query-parameters-test - (testing "POST /api/dashboard/:dashboard-id/card/:card-id/query" - (with-chain-filter-fixtures [{{dashboard-id :id} :dashboard, {card-id :id} :card, {dashcard-id :id} :dashcard}] - (let [url (dashboard-card-query-url dashboard-id card-id dashcard-id)] - (testing "parameters" - (testing "Should respect valid parameters" - (is (schema= (dashboard-card-query-expected-results-schema :row-count 6) - (mt/user-http-request :rasta :post 202 url - {:parameters [{:id "_PRICE_" - :value 4}]}))) - (testing "New parameter types" - (testing :number/= - (is (schema= (dashboard-card-query-expected-results-schema :row-count 94) - (mt/user-http-request :rasta :post 202 url - {:parameters [{:id "_PRICE_" - :type :number/= - :value [1 2 3]}]})))))) - (testing "Should return error if parameter doesn't exist" - (is (= "Dashboard does not have a parameter with ID \"_THIS_PARAMETER_DOES_NOT_EXIST_\"." - (mt/user-http-request :rasta :post 400 url - {:parameters [{:id "_THIS_PARAMETER_DOES_NOT_EXIST_" - :value 3}]})))) - (testing "Should return sensible error message for invalid parameter input" - (is (= {:errors {:parameters (str "value may be nil, or if non-nil, value must be an array. " - "Each value must be a parameter map with an 'id' key")}} - (mt/user-http-request :rasta :post 400 url - {:parameters {"_PRICE_" 3}})))) - (testing "Should ignore parameters that are valid for the Dashboard but not part of this Card (no mapping)" - (testing "Sanity check" - (is (schema= (dashboard-card-query-expected-results-schema :row-count 6) - (mt/user-http-request :rasta :post 202 url - {:parameters [{:id "_PRICE_" - :value 4}]})))) - (mt/with-temp-vals-in-db DashboardCard dashcard-id {:parameter_mappings []} - (is (schema= (dashboard-card-query-expected-results-schema :row-count 100) +;; see also [[metabase.query-processor.dashboard-test]] +(deftest dashboard-card-query-parameters-test + (testing "POST /api/dashboard/:dashboard-id/card/:card-id/query" + (with-chain-filter-fixtures [{{dashboard-id :id} :dashboard, {card-id :id} :card, {dashcard-id :id} :dashcard}] + (let [url (dashboard-card-query-url dashboard-id card-id dashcard-id)] + (testing "parameters" + (testing "Should respect valid parameters" + (is (schema= (dashboard-card-query-expected-results-schema :row-count 6) + (mt/user-http-request :rasta :post 202 url + {:parameters [{:id "_PRICE_" + :value 4}]}))) + (testing "New parameter types" + (testing :number/= + (is (schema= (dashboard-card-query-expected-results-schema :row-count 94) (mt/user-http-request :rasta :post 202 url {:parameters [{:id "_PRICE_" - :value 4}]}))))) - - ;; don't let people try to be sneaky and get around our validation by passing in a different `:target` - (testing "Should ignore incorrect `:target` passed in to API endpoint" + :type :number/= + :value [1 2 3]}]})))))) + (testing "Should return error if parameter doesn't exist" + (is (= "Dashboard does not have a parameter with ID \"_THIS_PARAMETER_DOES_NOT_EXIST_\"." + (mt/user-http-request :rasta :post 400 url + {:parameters [{:id "_THIS_PARAMETER_DOES_NOT_EXIST_" + :value 3}]})))) + (testing "Should return sensible error message for invalid parameter input" + (is (= {:errors {:parameters (str "value may be nil, or if non-nil, value must be an array. " + "Each value must be a parameter map with an 'id' key")}} + (mt/user-http-request :rasta :post 400 url + {:parameters {"_PRICE_" 3}})))) + (testing "Should ignore parameters that are valid for the Dashboard but not part of this Card (no mapping)" + (testing "Sanity check" (is (schema= (dashboard-card-query-expected-results-schema :row-count 6) (mt/user-http-request :rasta :post 202 url - {:parameters [{:id "_PRICE_" - :target [:dimension [:field (mt/id :venues :id) nil]] - :value 4}]})))))))))) + {:parameters [{:id "_PRICE_" + :value 4}]})))) + (mt/with-temp-vals-in-db DashboardCard dashcard-id {:parameter_mappings []} + (is (schema= (dashboard-card-query-expected-results-schema :row-count 100) + (mt/user-http-request :rasta :post 202 url + {:parameters [{:id "_PRICE_" + :value 4}]}))))) + + ;; don't let people try to be sneaky and get around our validation by passing in a different `:target` + (testing "Should ignore incorrect `:target` passed in to API endpoint" + (is (schema= (dashboard-card-query-expected-results-schema :row-count 6) + (mt/user-http-request :rasta :post 202 url + {:parameters [{:id "_PRICE_" + :target [:dimension [:field (mt/id :venues :id) nil]] + :value 4}]}))))))))) (defn- parse-export-format-results [^bytes results export-format] (with-open [is (java.io.ByteArrayInputStream. results)] diff --git a/test/metabase/models/params/card_values_test.clj b/test/metabase/models/params/card_values_test.clj new file mode 100644 index 00000000000..3d721d64a5f --- /dev/null +++ b/test/metabase/models/params/card_values_test.clj @@ -0,0 +1,118 @@ +(ns metabase.models.params.card-values-test + (:require + [clojure.test :refer :all] + [metabase.models :refer [Card]] + [metabase.models.params.card-values :as params.card-values] + [metabase.test :as mt])) + +(deftest with-mbql-card-test + (doseq [dataset? [true false]] + (testing (format "source card is a %s" (if dataset? "model" "question")) + (binding [params.card-values/*max-rows* 3] + (testing "with simple mbql" + (mt/with-temp* [Card [{card-id :id} + (merge (mt/card-with-source-metadata-for-query (mt/mbql-query venues)) + {:database_id (mt/id) + :dataset dataset? + :table_id (mt/id :venues)})]] + (testing "get values" + (is (=? {:has_more_values true, + :values ["Red Medicine" + "Stout Burgers & Beers" + "The Apple Pan"]} + (params.card-values/values-from-card + card-id + (mt/$ids $venues.name))))) + + (testing "case in-sensitve search test" + (is (=? {:has_more_values false + :values ["Liguria Bakery" "Noe Valley Bakery"]} + (params.card-values/values-from-card + card-id + (mt/$ids $venues.name) + "bakery")))))) + + (testing "has aggregation column" + (mt/with-temp* [Card [{card-id :id} + (merge (mt/card-with-source-metadata-for-query + (mt/mbql-query venues + {:aggregation [[:sum $venues.price]] + :breakout [[:field %categories.name {:source-field %venues.category_id}]]})) + {:database_id (mt/id) + :dataset dataset? + :table_id (mt/id :venues)})]] + + (testing "get values from breakout columns" + (is (=? {:has_more_values true, + :values ["American" "Artisan" "Asian"]} + (params.card-values/values-from-card + card-id + (mt/$ids $categories.name))))) + + (testing "get values from aggregation column" + (is (=? {:has_more_values true, + :values [20 4 4]} + (params.card-values/values-from-card + card-id + [:field "sum" {:base-type :type/Float}])))) + + (testing "doing case in-sensitve search on breakout columns" + (is (=? {:has_more_values false + :values ["Bakery"]} + (params.card-values/values-from-card + card-id + (mt/$ids $categories.name) + "bakery")))))) + + (testing "should disable remapping when getting fk columns" + (mt/with-column-remappings [venues.name {"Red Medicine" "RM", + "Stout Burgers & Beers" "SBB", + "The Apple Pan" "TAP"}] + (mt/with-temp* [Card [{card-id :id} + (merge (mt/card-with-source-metadata-for-query + (mt/mbql-query venues)) + + {:database_id (mt/id) + :dataset dataset? + :table_id (mt/id :venues)})]] + + (testing "get values returns the value, not remapped values" + (is (=? {:has_more_values true, + :values ["Red Medicine" "Stout Burgers & Beers" "The Apple Pan"]} + (params.card-values/values-from-card + card-id + (mt/$ids $categories.name))))) + + (testing "search with the value, not remapped values" + (is (=? {:has_more_values false, + :values ["Red Medicine"]} + (params.card-values/values-from-card + card-id + (mt/$ids $categories.name) + "medicine"))))))))))) + +(deftest with-native-card-test + (doseq [dataset? [true false]] + (testing (format "source card is a %s with native question" (if dataset? "model" "question")) + (binding [params.card-values/*max-rows* 3] + (mt/with-temp* [Card [{card-id :id} + (merge (mt/card-with-source-metadata-for-query + (mt/native-query {:query "select * from venues where lower(name) like '%red%'"})) + {:database_id (mt/id) + :dataset dataset? + :table_id (mt/id :venues)})]] + ;; HACK: run the card so the card's result_metadata is available + (testing "get values from breakout columns" + (is (=? {:has_more_values false, + :values ["Red Medicine" "Fred 62"]} + (params.card-values/values-from-card + card-id + [:field "NAME" {:base-type :type/Float}])))) + + (testing "doing case in-sensitve search on breakout columns" + (is (=? {:has_more_values false + :values ["Red Medicine"]} + (params.card-values/values-from-card + card-id + [:field "NAME" {:base-type :type/Float}] + "medicine"))))))))) diff --git a/test/metabase/test.clj b/test/metabase/test.clj index 2992b6f9676..23a3db99f3b 100644 --- a/test/metabase/test.clj +++ b/test/metabase/test.clj @@ -154,6 +154,7 @@ with-bigquery-fks] [qp.test-util + card-with-source-metadata-for-query store-contents with-database-timezone-id with-everything-store -- GitLab