Utility functions for writing SQL drivers. | (ns metabase.driver.sql.util (:require [clojure.string :as str] [metabase.driver.sql.query-processor :as sql.qp] [metabase.query-processor.error-type :as qp.error-type] [metabase.util :as u] [metabase.util.honey-sql-2 :as h2x] [metabase.util.i18n :refer [tru]] [metabase.util.log :as log] [metabase.util.malli :as mu]) (:import (com.github.vertical_blank.sqlformatter SqlFormatter SqlFormatter$Formatter) (com.github.vertical_blank.sqlformatter.core DialectConfig) (com.github.vertical_blank.sqlformatter.languages Dialect))) |
(set! *warn-on-reflection* true) | |
Quote unqualified string or keyword identifier(s) by passing them to (quote-name :mysql :field "wow") ; -> " You should only use this function for places where you are not using HoneySQL, such as queries written directly in
SQL. For HoneySQL forms, | (mu/defn quote-name
"Quote unqualified string or keyword identifier(s) by passing them to `h2x/identifier`, then calling HoneySQL `format`
on the resulting `Identifier`. Uses the `sql.qp/quote-style` of the current driver. You can implement `->honeysql`
for `Identifier` if you need custom behavior here.
(quote-name :mysql :field \"wow\") ; -> \"`wow`\"
(quote-name :h2 :field \"wow\") ; -> \"\\\"WOW\\\"\"
You should only use this function for places where you are not using HoneySQL, such as queries written directly in
SQL. For HoneySQL forms, `Identifier` is converted to SQL automatically when it is compiled."
[driver :- :keyword
identifier-type :- h2x/IdentifierType
& components]
(first
(sql.qp/format-honeysql driver (apply h2x/identifier identifier-type components)))) |
+----------------------------------------------------------------------------------------------------------------+ | Deduplicate Field Aliases | +----------------------------------------------------------------------------------------------------------------+ | |
(mu/defn- increment-identifier-string :- :string
[last-component :- :string]
(if-let [[_ existing-suffix] (re-find #"^.*_(\d+$)" last-component)]
;; if last-component already has an alias like col_2 then increment it to col_3
(let [new-suffix (str (inc (Integer/parseInt existing-suffix)))]
(str/replace last-component (re-pattern (str existing-suffix \$)) new-suffix))
;; otherwise just stick a _2 on the end so it's col_2
(str last-component "_2"))) | |
Add an appropriate suffix to a keyword (increment-identifier :mycol) ; -> :mycol_2 (increment-identifier :mycol2) ; -> :mycol3 | (mu/defn- increment-identifier
[[_tag identifier-type components] :- h2x/Identifier]
(let [components' (concat
(butlast components)
[(increment-identifier-string (u/qualified-name (last components)))])]
(apply h2x/identifier identifier-type components'))) |
Make sure all the columns in | (defn select-clause-alias-everything
[select-clause]
(for [col select-clause]
(cond
;; if something's already an alias form like [:table.col :col] it's g2g
(and (sequential? col)
(not (h2x/identifier? col)))
col
;; otherwise we *should* be dealing with an Identifier. If so, take the last component of the Identifier and use
;; that as the alias.
;;
;; TODO - could this be done using `->honeysql` or `field->alias` instead?
(h2x/identifier? col)
(let [[_tag _identifier-type components] col]
[col (h2x/identifier :field-alias (last components))])
:else
(do
(log/errorf "Don't know how to alias %s, expected an h2x/identifier" (pr-str col))
[col col])))) |
Make sure every column in | (defn select-clause-deduplicate-aliases
[select-clause]
(if (= select-clause [:*])
;; if we're doing `SELECT *` there's no way we can deduplicate anything so we're SOL, return as-is
select-clause
;; otherwise we can actually deduplicate things
(loop [already-seen #{}, acc [], [[col alias] & more] (select-clause-alias-everything select-clause)]
(cond
;; if not more cols are left to deduplicate, we're done
(not col)
acc
;; otherwise if we've already used this alias, replace it with one like `identifier_2` and try agan
(contains? already-seen alias)
(recur already-seen acc (cons [col (increment-identifier alias)]
more))
;; otherwise if we haven't seen it record it as seen and move on to the next column
:else
(recur (conj already-seen alias) (conj acc [col alias]) more))))) |
Escape single quotes in a SQL string. (escape-sql "Tito's Tacos" :ansi) ; -> "Tito''s Tacos" (escape-sql "Tito's Tacos" :backslashes) ; -> "Tito\'s Tacos" !!!! VERY IMPORTANT !!!! DON'T RELY ON THIS FOR SANITIZING USER INPUT BEFORE RUNNING QUERIES! For user input, ALWAYS pass parameters separately (e.g. using | (defn escape-sql
"Escape single quotes in a SQL string. `escape-style` is either `:ansi` (escape a single quote with two single quotes)
or `:backslashes` (escape a single quote with a backslash).
(escape-sql \"Tito's Tacos\" :ansi) ; -> \"Tito''s Tacos\"
(escape-sql \"Tito's Tacos\" :backslashes) ; -> \"Tito\\'s Tacos\"
!!!! VERY IMPORTANT !!!!
DON'T RELY ON THIS FOR SANITIZING USER INPUT BEFORE RUNNING QUERIES!
For user input, *ALWAYS* pass parameters separately (e.g. using `?` in the SQL) where supported, or if unsupported,
encode the strings as hex and splice in something along the lines of `utf8_string(hex_decode(<hex-string>))`
instead. This is intended only for escaping trusted strings, or for generating the SQL equivalent version of an MBQL
query for debugging purposes or powering the 'convert to SQL' feature."
{:arglists '([s :ansi] [s :backslashes])}
^String [^String s escape-style]
(when s
(case escape-style
:ansi (str/replace s "'" "''")
:backslashes (-> s
(str/replace "\\" "\\\\")
(str/replace "'" "\\'"))))) |
Validate the arguments of convert-timezone. - if input column has timezone only target-timezone is required, throw exception if source-timezone is provided. - if input column doesn't have a timezone both target-timezone and source-timezone are required. | (defn validate-convert-timezone-args
[has-timezone? target-timezone source-timezone]
(when (and has-timezone? source-timezone)
(throw (ex-info (tru "input column already has a set timezone. Please remove the source parameter in convertTimezone.")
{:type qp.error-type/invalid-query
:target-timezone target-timezone
:source-timezone source-timezone})))
(when (and (not has-timezone?) (not source-timezone))
(throw (ex-info (tru "input column doesn''t have a set timezone. Please set the source parameter in convertTimezone to convert it.")
{:type qp.error-type/invalid-query
:target-timezone target-timezone
:source-timezone source-timezone})))) |
[[format-sql]] will expand parameterized values (e.g. {{#123}} -> { { # 123 } }). This function fixes that by removing whitespace from matching double-curly brace substrings. | (defn fix-sql-params
[sql]
(when (string? sql)
(let [rgx #"\{\s*\{\s*[^\}]+\s*\}\s*\}"]
(str/replace sql rgx (fn [match] (str/replace match #"\s*" "")))))) |
Mapping of dialect kw to dialect, used by sql formatter in [[format-sql]], to dialect. | (def dialects
{:db2 Dialect/Db2
:databricks Dialect/SparkSql
:mariadb Dialect/MariaDb
:mysql Dialect/MySql
:n1ql Dialect/N1ql
:plsql Dialect/PlSql
:postgres Dialect/PostgreSql
:redshift Dialect/Redshift
:sparksql Dialect/SparkSql
:standardsql Dialect/StandardSql
:tsql Dialect/TSql}) |
(def ^:private ^java.util.List additional-operators ["#>>" "!="]) | |
(defn- add-operators
^SqlFormatter$Formatter [^SqlFormatter$Formatter formatter]
(.extend formatter (reify java.util.function.UnaryOperator
(apply [_this config]
(.plusOperators ^DialectConfig config additional-operators))))) | |
Pretty format | (defn format-sql
[driver-or-dialect-kw sql]
(when (string? sql)
(let [dialect (get dialects driver-or-dialect-kw Dialect/StandardSql)
formatter (add-operators (SqlFormatter/of ^Dialect dialect))]
(.format formatter ^String sql)))) |
[[format-sql]] and [[fix-sql-params]] afterwards. For details see those functions. | (defn format-sql-and-fix-params [driver-or-dialect-kw sql] (-> (format-sql driver-or-dialect-kw sql) fix-sql-params)) |