diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index bf7e8aa04699f7678870968a2672396db13658de..77b9f3e5862fc943805e9dd4f630bf6e923f5a7c 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -16,12 +16,14 @@ [metabase.email.messages :as messages] [metabase.events :as events] [metabase.mbql.normalize :as mbql.normalize] + [metabase.mbql.util :as mbql.u] [metabase.models.bookmark :as bookmark :refer [CardBookmark]] [metabase.models.card :as card :refer [Card]] [metabase.models.collection :as collection :refer [Collection]] [metabase.models.database :refer [Database]] [metabase.models.interface :as mi] [metabase.models.moderation-review :as moderation-review] + [metabase.models.params.chain-filter :as chain-filter] [metabase.models.persisted-info :as persisted-info :refer [PersistedInfo]] [metabase.models.pulse :as pulse :refer [Pulse]] [metabase.models.query :as query] @@ -32,6 +34,7 @@ [metabase.models.view-log :refer [ViewLog]] [metabase.query-processor.async :as qp.async] [metabase.query-processor.card :as qp.card] + [metabase.query-processor.error-type :as qp.error-type] [metabase.query-processor.pivot :as qp.pivot] [metabase.query-processor.util :as qp.util] [metabase.related :as related] @@ -704,6 +707,118 @@ :js-int-to-string? false})) +;;; ------------------------------------------------ Parameters ------------------------------------------------- +(def ^:const result-limit + "How many results to return when getting values for a parameter." + 1000) + +(defn- field-clause->field-id + "Find the field id in a field clause if it exists. + + (field-clause->field-id [:field 3 nil]) + ;; -> 3" + [field-clause] + (mbql.u/match-one field-clause [:field (id :guard integer?) _] id)) + +(defn- template-tag-target->field-id + "Given a template tag target, find the field id that it's connected to. + Note that a target has a field id connect to it iff it's a field filter." + [card {:keys [id]}] + (if-let [template-tags (get-in card [:dataset_query :native :template-tags])] + (first (for [[_ template-tag] template-tags + :when (and (= (:id template-tag) id) + (= (:type template-tag) :dimension)) + :let [field-id (field-clause->field-id (:dimension template-tag))] + :when field-id] + field-id)) + (throw (ex-info (tru "Card with ID {0} does not have template tags." (:id card)) + {:card-id (:id card) + :template-tag-id id})))) + +(defn- target->field-id + "Find the field id that the target is connected to. + Target could be a `dimension`, in which target have the shape + + [:dimension [:field 3 nil]] + + Or it could be a `template-tag`, then it should have the shape + + [:template-tag {:id \"6006ad7d-036e-83ec-4d6f-30f82b98ac21\"}]" + [card [target-type target-args]] + (case target-type + :dimension (field-clause->field-id target-args) + :template-tag (template-tag-target->field-id card target-args) + nil)) + +(defn- param-id->field-ids + "Get Field ID(s) associated with a parameter in a Card. + + (param-id->field-ids (Card 62) \"ee876336\") + ;; -> #{276}" + [{:keys [parameter_mappings] :as card} param-id] + (into #{} (for [{:keys [target parameter_id]} parameter_mappings + :when (= parameter_id param-id) + :let [field-id (target->field-id card target)] + :when field-id] + field-id))) + +(s/defn param-values + "Given a `param-id`, returns a of possible values that it could choose from. + + ;; show me categories + (param-values (Card 62) \"ee876336\") + ;; -> (\"African\" \"American\" \"Artisan\" ...) + + ;; show me categories that contains \"Ameri\" + (param-values (Card 62) \"ee876336\" \"Ameri\") + ;; -> (\"American\") + " + ([card param-id] + (param-values card param-id nil)) + + ([card param-id query] + (when-not (seq (filter #(= (:id %) param-id) (:parameters card))) + (throw (ex-info (tru "Card does not have a parameter with the ID {0}" (pr-str param-id)) + {:status-code 400}))) + (let [field-ids (param-id->field-ids card param-id)] + (when (empty? field-ids) + (throw (ex-info (tru "Parameter {0} does not have any Fields associated with it" (pr-str param-id)) + {:param-id param-id + :status-code 400}))) + (try + (let [results (distinct (mapcat (if (seq query) + #(chain-filter/chain-filter-search % {} query :limit result-limit) + #(chain-filter/chain-filter % {} :limit result-limit)) + field-ids))] + ;; results can come back as [v ...] *or* as [[orig remapped] ...]. Sort by remapped value if that's the case + (if (sequential? (first results)) + (sort-by second results) + (sort results))) + (catch clojure.lang.ExceptionInfo e + (if (= (:type (u/all-ex-data e)) qp.error-type/missing-required-permissions) + (api/throw-403 e) + (throw e))))))) + +(api/defendpoint GET "/:id/params/:param-key/values" + "Fetch possible values of the parameter whose ID is `:param-id`. + + ;; fetch values for Card 1 parameter 'abc' that are possible + GET /api/card/1/params/abc/values" + [id param-key] + (let [card (api/read-check Card id)] + (param-values card param-key))) + +(api/defendpoint GET "/:id/params/:param-key/search/:query" + "Fetch possible values of the parameter whose ID is `:param-id` that contain `:query`. + + ;; fetch values for Card 1 parameter 'abc' that contain 'Cam' + GET /api/card/1/params/abc/search/Cam + + Currently limited to first 1000 results." + [id param-key query] + (let [card (api/read-check Card id)] + (param-values card param-key query))) + ;;; ----------------------------------------------- Sharing is Caring ------------------------------------------------ (api/defendpoint POST "/:card-id/public_link" @@ -766,6 +881,8 @@ :qp-runner qp.pivot/run-pivot-query :ignore_cache ignore_cache)) +;;; ----------------------------------------------- Persistence ------------------------------------------------ + (api/defendpoint POST "/:card-id/persist" "Mark the model (card) as persisted. Runs the query and saves it to the database backing the card and hot swaps this query in place of the model's query." diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 2f515fb4e217d584133959e3461785a6b7097903..9da4141ddbcddc4e885714e45140f2cad42bb736 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -14,6 +14,7 @@ [metabase.models :refer [Card CardBookmark Collection Dashboard Database ModerationReview Pulse PulseCard PulseChannel PulseChannelRecipient Table Timeline TimelineEvent ViewLog]] [metabase.models.moderation-review :as moderation-review] + [metabase.models.params.chain-filter-test :as chain-filter-test] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as perms-group] [metabase.models.revision :as revision :refer [Revision]] @@ -1838,6 +1839,167 @@ (name->position (:data (mt/user-http-request :crowberto :get 200 (format "collection/%s/items" coll-id-2) :model "card" :archived "false")))))))) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | PARAMETER VALUES ENDPOINTS | +;;; +----------------------------------------------------------------------------------------------------------------+ + +(defn- do-with-param-values-fixtures + [query-type card-values f] + {:pre [(#{:native :query} query-type)]} + (mt/with-temp* [Card [card]] + (let [card-defaults + (if (= query-type :query) + ;; notebook query with parameters are fields + {:database_id (mt/id) + :table_id (mt/id :venues) + :dataset_query (mt/mbql-query venues) + :parameters [{:name "Category Name" + :slug "category_name" + :id "_CATEGORY_NAME_" + :type "category"} + {:name "Category ID" + :slug "category_id" + :id "_CATEGORY_ID_" + :type "category"}] + :parameter_mappings [{:parameter_id "_CATEGORY_NAME_" + :card_id (:id card) + :target [:dimension (mt/$ids venues $category_id->categories.name)]} + {:parameter_id "_CATEGORY_ID_" + :card_id (:id card) + :target [:dimension (mt/$ids venues $category_id)]}]} + ;; native query with parameters are template tags + {:database_id (mt/id) + :query_type :native + :dataset_query {:database (mt/id) + :type :native + :native + {:query (str "SELECT * FROM VENUES WHERE {{category}} and {{category_id}};") + :template-tags {"category" {:id "c7fcf1fa" + :name "category" + :display-name "Category" + :type :dimension + :dimension [:field (mt/$ids venues $category_id->categories.name) nil] + :widget-type :string/=} + "category_id" {:id "a3cd3f3b" + :name "category_id" + :display-name "Category" + :type :dimension + :dimension [:field (mt/$ids venues $category_id) nil] + :widget-type :number/=}}}} + + :parameters [{:name "Category_name" + :slug "category_name" + :id "_CATEGORY_NAME_" + :type "category"} + {:name "Category ID" + :slug "category_id" + :id "_CATEGORY_ID_" + :type "category"}] + :parameter_mappings [{:parameter_id "_CATEGORY_NAME_" + :card_id (:id card) + :target [:template-tag {:id "c7fcf1fa"}]} + {:parameter_id "_CATEGORY_ID_" + :card_id (:id card) + :target [:template-tag {:id "a3cd3f3b"}]}]})] + (db/update! Card (:id card) + (merge card-defaults card-values))) + (f {:card card + :param-ids {:category-name "_CATEGORY_NAME_" + :category-id "_CATEGORY_ID_"}}))) + +(defmacro ^:private with-param-values-fixtures + "Create a query and its parameters." + {:style/indent 2} + [query-type [binding card-values] & body] + `(do-with-param-values-fixtures ~query-type ~card-values (fn [~binding] ~@body))) + +(defn- param-values-values-url [card-or-id param-id] + (format "card/%d/params/%s/values" (u/the-id card-or-id) (name param-id))) + +(defn- param-values-search-url [card-or-id param-id query] + (str (format "card/%d/params/%s/search/" (u/the-id card-or-id) (name param-id)) + query)) + +(deftest param-values-test + (testing "GET /api/card/:id/params/:param-id/values" + (doseq [query-type [:query :native]] + (testing (format "With %s question" (name query-type)) + (with-param-values-fixtures query-type [{:keys [card param-ids]}] + (testing "Show me names of categories" + (is (= ["African" "American" "Artisan"] + (take 3 (mt/user-http-request :rasta :get 200 (param-values-values-url + card + (:category-name param-ids)))))))) + + (testing "Should require perms for the Card" + (mt/with-non-admin-groups-no-root-collection-perms + (mt/with-temp Collection [collection] + (with-param-values-fixtures query-type [{:keys [card param-ids]} {:collection_id (:id collection)}] + (is (= "You don't have permissions to do that." + (mt/user-http-request :rasta :get 403 (param-values-values-url + card + (:category-name param-ids))))))))) + + (testing "should check perms for the Fields in question" + (mt/with-temp-copy-of-db + (with-param-values-fixtures query-type [{:keys [card param-ids]}] + (perms/revoke-data-perms! (perms-group/all-users) (mt/id)) + (is (= "You don't have permissions to do that." + (mt/user-http-request :rasta :get 403 (param-values-values-url + card + (:category-name param-ids)))))))))))) + +(deftest param-values-search-test + (testing "GET /api/card/:id/params/:param-id/search/:query" + (doseq [query-type [:native :query]] + (testing (format "With %s question" (name query-type)) + (with-param-values-fixtures :query [{:keys [card param-ids]}] + (let [url (param-values-search-url card (:category-name param-ids) "bar")] + (testing (str "\n" url) + (testing "\nShow me names of categories that include 'bar' (case-insensitive)" + (is (= ["Bar" "Gay Bar" "Juice Bar"] + (take 3 (mt/user-http-request :rasta :get 200 url))))))) + + (let [url (param-values-search-url card (:category-name param-ids) "house")] + (testing "\nShow me names of categories that include 'house' that have expensive venues (price = 4)" + (is (= ["Steakhouse"] + (take 3 (mt/user-http-request :rasta :get 200 url)))))) + + (testing "Should require a non-empty query" + (doseq [query [nil + "" + " " + "\n"]] + (let [url (param-values-search-url card (:category-name param-ids) query)] + (is (= "API endpoint does not exist." + (mt/user-http-request :rasta :get 404 url))))))) + + (testing "Should require perms for the card" + (mt/with-non-admin-groups-no-root-collection-perms + (mt/with-temp Collection [collection] + (with-param-values-fixtures :query [{:keys [card param-ids]} {:collection_id (:id collection)}] + (let [url (param-values-search-url card (:category-name param-ids) "s")] + (testing (str "\n url") + (is (= "You don't have permissions to do that." + (mt/user-http-request :rasta :get 403 url))))))))))))) + +(deftest param-values-human-readable-values-remapping-test + (testing "Get param values for Fields that have Human-Readable values\n" + (doseq [query-type [:native :query]] + (testing (format "With %s question" (name query-type)) + (chain-filter-test/with-human-readable-values-remapping + (with-param-values-fixtures query-type [{:keys [card param-ids]}] + (testing "GET /api/card/:id/params/:param-id/values" + (let [url (param-values-values-url card (:category-id param-ids))] + (is (= [[2 "American"] + [3 "Artisan"]] + (take 2 (mt/user-http-request :rasta :get 200 url)))))) + + (testing "GET /api/card/:id/params/:param-id/search/:query" + (let [url (param-values-search-url card (:category-id param-ids) "house")] + (is (= [[67 "Steakhouse"]] + (take 1 (mt/user-http-request :rasta :get 200 url)))))))))))) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | PUBLIC SHARING ENDPOINTS | ;;; +----------------------------------------------------------------------------------------------------------------+