(ns metabase.driver.common.parameters.parse (:require [clojure.string :as str] [metabase.driver.common.parameters :as params] [metabase.lib.schema.common :as lib.schema.common] [metabase.query-processor.error-type :as qp.error-type] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.log :as log] [metabase.util.malli :as mu]) (:import (metabase.driver.common.parameters Optional Param))) | |
(set! *warn-on-reflection* true) | |
(def ^:private StringOrToken [:or
:string
[:map
[:token :keyword]
[:text :string]]]) | |
(def ^:private ParsedToken [:or :string (lib.schema.common/instance-of-class Param) (lib.schema.common/instance-of-class Optional)]) | |
Returns any adjacent strings in coll combined together | (defn- combine-adjacent-strings
[coll]
(apply concat
(for [subseq (partition-by string? coll)]
(if (string? (first subseq))
[(apply str subseq)]
subseq)))) |
Returns a vector of [index match] for string or regex pattern found in s | (defn- find-token
[s pattern]
(if (string? pattern)
(when-let [index (str/index-of s pattern)]
[index pattern])
(let [m (re-matcher pattern s)]
(when (.find m)
[(.start m) (subs s (.start m) (.end m))])))) |
(defn- tokenize-one [s pattern token]
(loop [acc [], s s]
(if (empty? s)
acc
(if-let [[index text] (find-token s pattern)]
(recur (conj acc (subs s 0 index) {:text text :token token})
(subs s (+ index (count text))))
(conj acc s))))) | |
(def ^:private param-token-patterns
[["[[" :optional-begin]
["]]" :optional-end]
;; param-begin should only match the last two opening brackets in a sequence of > 2, e.g.
;; [{$match: {{{x}}, field: 1}}] should parse to ["[$match: {" (param "x") ", field: 1}}]"]
[#"(?s)\{\{(?!\{)" :param-begin]
["}}" :param-end]
["'" :single-quote]]) | |
(def ^:private sql-token-patterns
(concat
[["/*" :block-comment-begin]
["*/" :block-comment-end]
["--" :line-comment-begin]
["\n" :newline]]
param-token-patterns)) | |
(mu/defn- tokenize :- [:sequential StringOrToken]
[s :- :string
handle-sql-comments :- :boolean]
(reduce
(fn [strs [token-str token]]
(filter
(some-fn keyword? seq)
(mapcat
(fn [s]
(if-not (string? s)
[s]
(tokenize-one s token-str token)))
strs)))
[s]
(if handle-sql-comments
sql-token-patterns
param-token-patterns))) | |
(defn- param [& parsed]
(let [[k & more] (combine-adjacent-strings parsed)]
(when (or (seq more)
(not (string? k)))
(throw (ex-info (tru "Invalid '''{{...}}''' clause: expected a param name")
{:type qp.error-type/invalid-query})))
(let [k (str/trim k)]
(when (empty? k)
(throw (ex-info (tru "'''{{...}}''' clauses cannot be empty.")
{:type qp.error-type/invalid-query})))
(params/->Param k)))) | |
(defn- optional [& parsed]
(when-not (some params/Param? parsed)
(throw (ex-info (tru "[[...]] clauses must contain at least one '''{{...}}''' clause.")
{:type qp.error-type/invalid-query})))
(params/->Optional (combine-adjacent-strings parsed))) | |
(mu/defn- parse-tokens* :- [:tuple
[:sequential ParsedToken]
[:maybe [:sequential StringOrToken]]]
[tokens :- [:maybe [:sequential StringOrToken]]
optional-level :- :int
param-level :- :int
in-string? :- :boolean
comment-mode :- [:maybe [:enum :block-comment-begin :line-comment-begin]]]
(loop [acc [],
[string-or-token & more] tokens
in-string? in-string?]
(cond
(nil? string-or-token)
(if (or (pos? optional-level) (pos? param-level))
(throw (ex-info (tru "Invalid query: found ''[['' or '''{{''' with no matching '']]'' or ''}}''")
{:type qp.error-type/invalid-query}))
[acc nil])
(string? string-or-token)
(recur (conj acc string-or-token) more in-string?)
:else
(let [{:keys [text token]} string-or-token]
(case token
:optional-begin
(if comment-mode
(recur (conj acc text) more in-string?)
(let [[parsed more] (try (let [[parsed more] (parse-tokens* more (inc optional-level) param-level in-string? comment-mode)]
[(apply optional parsed) more])
(catch clojure.lang.ExceptionInfo e
(if (and in-string? (= qp.error-type/invalid-query (:type (ex-data e))))
[text more]
(throw e))))]
(recur (conj acc parsed) more in-string?)))
:param-begin
(if comment-mode
(recur (conj acc text) more in-string?)
(let [[parsed more] (try (let [[parsed more] (parse-tokens* more optional-level (inc param-level) in-string? comment-mode)]
[(apply param parsed) more])
(catch clojure.lang.ExceptionInfo e
(if (and in-string? (= qp.error-type/invalid-query (:type (ex-data e))))
[text more]
(throw e))))]
(recur (conj acc parsed) more in-string?)))
(:line-comment-begin :block-comment-begin)
(if (or comment-mode (pos? optional-level) in-string?)
(recur (conj acc text) more in-string?)
(let [[parsed more] (parse-tokens* more optional-level param-level in-string? token)]
(recur (into acc (cons text parsed)) more in-string?)))
:block-comment-end
(if (= comment-mode :block-comment-begin)
[(conj acc text) more]
(recur (conj acc text) more in-string?))
:newline
(if (= comment-mode :line-comment-begin)
[(conj acc text) more]
(recur (conj acc text) more in-string?))
:optional-end
(if (pos? optional-level)
[acc more]
(recur (conj acc text) more in-string?))
:param-end
(if (pos? param-level)
[acc more]
(recur (conj acc text) more in-string?))
:single-quote
(recur (conj acc text) more (not in-string?))))))) | |
(mu/defn parse :- [:sequential ParsedToken]
"Attempts to parse parameters in string `s`. Parses any optional clauses or parameters found, and returns a sequence
of non-parameter string fragments (possibly) interposed with `Param` or `Optional` instances.
If `handle-sql-comments` is true (default) then we make a best effort to ignore params in SQL comments."
([s :- :string]
(parse s true))
([s :- :string
handle-sql-comments :- :boolean]
(let [tokenized (tokenize s handle-sql-comments)]
(if (= [s] tokenized)
[s]
(do
(log/tracef "Tokenized native query ->\n%s" (u/pprint-to-str tokenized))
(u/prog1 (combine-adjacent-strings (first (parse-tokens* tokenized 0 0 false nil)))
(log/tracef "Parsed native query ->\n%s" (u/pprint-to-str <>)))))))) | |