Custom values for Parameters.

A parameter with custom values will need to define a source: - static-list: the values is pre-defined and stored inside parameter's config - card: the values is a column from a saved question

(ns metabase.models.params.custom-values
  (:require
   [clojure.string :as str]
   [metabase.legacy-mbql.util :as mbql.u]
   [metabase.lib.ident :as lib.ident]
   [metabase.models.card :refer [Card]]
   [metabase.models.interface :as mi]
   [metabase.query-processor :as qp]
   [metabase.query-processor.util :as qp.util]
   [metabase.util :as u]
   [metabase.util.i18n :refer [tru]]
   [metabase.util.malli :as mu]
   [metabase.util.malli.schema :as ms]
   [toucan2.core :as t2]))

------------------------------------------------- source=static-list --------------------------------------------------

(mu/defn- normalize-query :- :string
  "Normalize a `query` to lower-case."
  [query :- :string]
  (u/lower-case-en (str/trim query)))

Filters for values that match query.

Values could have 2 shapes - [[value1], [value2]] - [[value2, label2], [value2, label2]] - we search using label in this case

(defn- query-matches
  [query values]
  (let [normalized-query (normalize-query query)]
    (filter (fn [v] (str/includes? (normalize-query (if (= (count v) 1)
                                                      (first v)
                                                      (second v)))
                                   normalized-query)) values)))
(defn- static-list-values
  [{values-source-options :values_source_config :as _param} query]
  (when-let [values (:values values-source-options)]
    (let [wrapped-values (map (fn [v] (if-not (sequential? v) [v] v)) values)]
      {:values          (if query
                          (query-matches query wrapped-values)
                          wrapped-values)
       :has_more_values false})))

---------------------------------------------------- source=card ------------------------------------------------------

Maximum number of rows returned when running a card. It's 1000 because it matches with the limit for chain-filter. Maybe we should lower it for the sake of displaying a parameter dropdown.

(def ^:dynamic *max-rows*
  1000)
(defn- values-from-card-query
  [card value-field-ref opts]
  (let [query-string (:query-string opts)
        value-base-type (:base_type (qp.util/field->field-info value-field-ref (:result_metadata card)))
        new-filter      [:and
                         [(if (isa? value-base-type :type/Text)
                            :not-empty
                            :not-null)
                          value-field-ref]
                         (when query-string
                           (if-not (isa? value-base-type :type/Text)
                             [:= value-field-ref query-string]
                             [:contains [:lower value-field-ref] (u/lower-case-en query-string)]))]]
    {:database (:database_id card)
     :type     :query
     :query    (if-let [inner-mbql (and (not= (:type card) :model)
                                        (-> card :dataset_query :query))]
                 ;; MBQL query - hijack the final stage, drop its aggregation and breakout (if any).
                 (let [target-stage (:stage-number opts)
                       last-stage   (mbql.u/legacy-last-stage-number inner-mbql)
                       inner-mbql   (if (and target-stage last-stage
                                             (= (inc last-stage) target-stage))
                                      {:source-query inner-mbql}
                                      inner-mbql)]
                   (-> inner-mbql
                       (dissoc :aggregation :order-by)
                       (assoc :breakout        [value-field-ref]
                              :breakout-idents (lib.ident/indexed-idents 1))
                       (update :limit (fnil min *max-rows*) *max-rows*)
                       (update :filter (fn [old]
                                         (cond->> new-filter
                                           old (conj [:and old]))))))
                 ;; Model or Native query - wrap it with a new MBQL stage.
                 {:source-table    (format "card__%d" (:id card))
                  :breakout        [value-field-ref]
                  :breakout-idents (lib.ident/indexed-idents 1)
                  :limit           *max-rows*
                  :filter          new-filter})
     :middleware {:disable-remaps? true}}))

Get distinct values of 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] ;; :breakout [value-field] ;; :filter [:contains [:lower value-field] "red"] ;; :limit max-rows} => {:values [["Red Medicine"]] :hasmorevalues false}

(mu/defn values-from-card
  ([card value-field]
   (values-from-card card value-field nil))
  ([card            :- (ms/InstanceOf Card)
    value-field-ref :- ms/LegacyFieldOrExpressionReference
    opts            :- [:maybe :map]]
   (let [mbql-query   (values-from-card-query card value-field-ref opts)
         result       (qp/process-query mbql-query)
         values       (get-in result [:data :rows])]
     {:values         values
      ;; If the row_count returned = the limit we specified, then it's probably has more than that.
      ;; If the query has its own limit smaller than *max-rows*, then there's no more values.
      :has_more_values (= (:row_count result) *max-rows*)})))

Given a param and query returns the values.

(defn card-values
  [{config :values_source_config :as _param} query]
  (let [card-id (:card_id config)
        card    (t2/select-one Card :id card-id)]
    (values-from-card card (:value_field config) {:query-string query})))
(defn- can-get-card-values?
  [card value-field]
  (boolean
   (and (not (:archived card))
        (some? (qp.util/field->field-info value-field (:result_metadata card))))))

--------------------------------------------- Putting it together ----------------------------------------------

(mu/defn parameter->values :- ms/FieldValuesResult
  "Given a parameter with a custom-values source, return the values.
  `default-case-thunk` is a 0-arity function that returns values list when:
  - :values_source_type = card but the card is archived or the card no longer contains the value-field.
  - :values_source_type = nil."
  [parameter query default-case-thunk]
  (case (:values_source_type parameter)
    "static-list" (static-list-values parameter query)
    "card"        (let [card (t2/select-one Card :id (get-in parameter [:values_source_config :card_id]))]
                    (when-not (mi/can-read? card)
                      (throw (ex-info "You don't have permissions to do that." {:status-code 403})))
                    (if (can-get-card-values? card (get-in parameter [:values_source_config :value_field]))
                      (card-values parameter query)
                      (default-case-thunk)))
    nil           (default-case-thunk)
    (throw (ex-info (tru "Invalid parameter source {0}" (:values_source_type parameter))
                    {:status-code 400
                     :parameter parameter}))))