diff --git a/shared/src/metabase/shared/parameters/parameters.cljc b/shared/src/metabase/shared/parameters/parameters.cljc index 9f72615c235d402320d7df614d5234d37b59608d..c8b478a30c49e7b2d58d46739dec78ec3ccf20be 100644 --- a/shared/src/metabase/shared/parameters/parameters.cljc +++ b/shared/src/metabase/shared/parameters/parameters.cljc @@ -1,5 +1,6 @@ (ns metabase.shared.parameters.parameters - "Util functions for dealing with parameters" + "Util functions for dealing with parameters. Primarily used for substituting parameters into variables in Markdown + dashboard cards." #?@ (:clj [(:require [clojure.string :as str] @@ -113,36 +114,92 @@ [text] (str/replace text escaped-chars-regex #(str \\ %))) -(defn- replacement - [tag->param locale match] - (let [tag-name (second match) - param (get tag->param tag-name) +(defn- value + [tag-name tag->param locale] + (let [param (get tag->param tag-name) value (:value param) tyype (:type param)] - (if value + (when value (try (-> (formatted-value tyype value locale) escape-chars) (catch #?(:clj Throwable :cljs js/Error) _ ;; If we got an exception (most likely during date parsing/formatting), fallback to the default ;; implementation of formatted-value - (formatted-value :default value locale))) - ;; If this parameter has no value, return the original {{tag}} so that no substitution is done. - (first match)))) - -(defn- normalize-parameter - "Normalize a single parameter by calling [[mbql.normalize/normalize-fragment]] on it, and converting all string keys - to keywords." - [parameter] - (->> (mbql.normalize/normalize-fragment [:parameters] [parameter]) - first - (reduce-kv (fn [acc k v] (assoc acc (keyword k) v)) {}))) + (formatted-value :default value locale)))))) (def ^:private template-tag-regex "A regex to find template tags in a text card on a dashboard. This should mirror the regex used to find template tags in native queries, with the exception of snippets and card ID references (see the metabase-lib function - `recognizeTemplateTags` for that regex)." + `recognizeTemplateTags` for that regex). + + If you modify this, also modify `template-tag-splitting-regex` below." #"\{\{\s*([A-Za-z0-9_\.]+?)\s*\}\}") +;; Represents a variable parsed out of a text card. `tag` contains the tag name alone, as a string. `source` contains +;; the full original syntax for the parameter) +(defrecord ^:private TextParam [tag source] + Object + (toString + [x] + (or (:value x) source))) + +(defn- TextParam? + [x] + (instance? TextParam x)) + +(def ^:private template-tag-splitting-regex + (let [base "\\{\\{\\s*[A-Za-z0-9_\\.]+?\\s*\\}\\}"] + ;; Use lookahead and lookbehind to retain matches in split + (re-pattern (str "(?<=" base ")|(?=" base ")")))) + +(defn- split-on-tags + "Given the text of a Markdown card, splits it into a sequence of alternating strings and TextParam records." + [text] + (let [split-text (str/split text template-tag-splitting-regex)] + (map (fn [text] + (if-let [[_, match] (re-matches template-tag-regex text)] + (->TextParam match text) + text)) + split-text))) + +(defn- join-consecutive-strings + "Given a vector of strings and/or TextParam, concatenate consecutive strings and TextParams without values." + [strs-or-vars] + (->> strs-or-vars + (partition-by (fn [str-or-var] + (or (string? str-or-var) + (not (:value str-or-var))))) + (mapcat (fn [strs-or-var] + (if (string? (first strs-or-var)) + [(str/join strs-or-var)] + strs-or-var))))) + +(defn- add-values-to-variables + "Given `split-text`, containing a list of alternating strings and TextParam, add a :value key to any TextParams + with a corresponding value in `tag->normalized-param`." + [tag->normalized-param locale split-text] + (map + (fn [maybe-variable] + (if (TextParam? maybe-variable) + (assoc maybe-variable :value (value (:tag maybe-variable) tag->normalized-param locale)) + maybe-variable)) + split-text)) + +(def ^:private optional-block-regex + #"\[\[.+\]\]") + +(def ^:private non-optional-block-regex + #"\[\[(.+?)\]\]") + +(defn- strip-optional-blocks + "Removes any [[optional]] blocks from individual strings in `split-text`, which are blocks that have no parameters + with values. Then, concatenates the full string and removes the brackets from any remaining optional blocks." + [split-text] + (let [s (->> split-text + (map #(if (TextParam? %) % (str/replace % optional-block-regex ""))) + str/join)] + (str/replace s non-optional-block-regex second))) + (defn ^:export tag_names "Given the content of a text dashboard card, return a set of the unique names of template tags in the text." [text] @@ -152,6 +209,14 @@ #?(:clj tag-names :cljs (clj->js tag-names)))) +(defn- normalize-parameter + "Normalize a single parameter by calling [[mbql.normalize/normalize-fragment]] on it, and converting all string keys + to keywords." + [parameter] + (->> (mbql.normalize/normalize-fragment [:parameters] [parameter]) + first + (reduce-kv (fn [acc k v] (assoc acc (keyword k) v)) {}))) + (defn ^:export substitute_tags "Given the context of a text dashboard card, replace all template tags in the text with their corresponding values, formatted and escaped appropriately." @@ -165,4 +230,17 @@ (assoc acc tag (normalize-parameter param))) {} tag->param)] - (str/replace text template-tag-regex (partial replacement tag->normalized-param locale)))))) + ;; Most of the functions in this pipeline are relating to handling optional blocks in the text which use + ;; the [[ ]] syntax. + ;; For example, given an input "[[a {{b}}]] [[{{c}}]]", where `b` has no value and `c` = 3: + ;; 1. `split-on-tags` => + ;; ("[[a " {:tag "b" :source "{{b}}"} "]] [[" {:tag "c" :source "{{c}}"} "]]") + ;; 2. `add-values-to-variables` => + ;; ("[[a " {:tag "b" :source "{{b}}" :value nil} "]] [[" {:tag "c" :source "{{c}}" :value 3} "]]") + ;; 3. `join-consecutive-strings` => ("[[a {{b}}]] [[" {:tag "b" :source "{{c}}" :value 3} "]]) + ;; 4. `strip-optional-blocks` => "3" + (->> text + split-on-tags + (add-values-to-variables tag->normalized-param locale) + join-consecutive-strings + strip-optional-blocks))))) diff --git a/shared/test/metabase/shared/parameters/parameters_test.cljc b/shared/test/metabase/shared/parameters/parameters_test.cljc index 921199971ae0b5d88ecc1af3094ba7f3d0417ade..2ee352b83353c3393cbee762683b167fa477bd8c 100644 --- a/shared/test/metabase/shared/parameters/parameters_test.cljc +++ b/shared/test/metabase/shared/parameters/parameters_test.cljc @@ -185,3 +185,49 @@ "{{foo}}" {"foo" {:type :date/month-year :value "2019-08"}} "agosto\\, 2019")))) + +(t/deftest substitute-tags-optional-blocks-test + (t/testing "Optional blocks are removed when necessary" + (t/are [text tag->param expected] (= expected (params/substitute_tags text tag->param)) + "[[{{foo}}]]" + {} + "" + + "[[{{foo}}]]" + {"foo" {:type :string/= :value "bar"}} + "bar" + + "Customers[[ with over {{order_count}} orders]]" + {"order_count" {:type :number/= :value nil}} + "Customers" + + "Customers[[ with over {{order_count}} orders]]" + {"order_count" {:type :number/= :value 10}} + "Customers with over 10 orders" + + ;; Optional block is retained when *any* parameters within are substituted + "[[{{foo}} {{baz}}]]" + {"foo" {:type :string/= :value "bar"}} + "bar {{baz}}" + + ;; Make sure `join-consecutive-strings` retains consecutive non-strings (this was a bug during implementation) + "[[{{foo}}{{foo}}]]" + {"foo" {:type :string/= :value "foo"}} + "foofoo" + + "[[{{foo}}]] [[{{bar}}]]" + {"foo" {:type :string/= :value 1} "bar" {:type :string/= :value 2}} + "1 2" + + "[[{{foo}}]" + {"foo" {:type :string/= :value "bar"}} + "[[bar]" + + "[{{foo}}]]" + {"foo" {:type :string/= :value "bar"}} + "[bar]]" + + ;; Don't strip square brackets that are in parameter values + "{{foo}}" + {"foo" {:type :string/= :value "[[bar]]"}} + "\\[\\[bar\\]\\]")))