Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
interface.clj 17.71 KiB
(ns metabase.query-processor.interface
  "Definitions of `Field`, `Value`, and other record types present in an expanded query.
   This namespace should just contain definitions of various protocols and record types; associated logic
   should go in `metabase.query-processor.expand`."
  (:require [metabase.models.field :as field]
            [metabase.util :as u]
            [metabase.util.schema :as su]
            [schema.core :as s])
  (:import clojure.lang.Keyword
           java.sql.Timestamp))

;;; # ------------------------------------------------------------ CONSTANTS ------------------------------------------------------------

(def ^:const absolute-max-results
  "Maximum number of rows the QP should ever return.

   This is coming directly from the max rows allowed by Excel for now ...
   https://support.office.com/en-nz/article/Excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3"
  1048576)


;;; # ------------------------------------------------------------ DYNAMIC VARS ------------------------------------------------------------

(def ^:dynamic ^Boolean *disable-qp-logging*
  "Should we disable logging for the QP? (e.g., during sync we probably want to turn it off to keep logs less cluttered)."
  false)


(def ^:dynamic *driver*
  "The driver that will be used to run the query we are currently parsing.
   Used by `assert-driver-supports` and other places.
   Always bound when running queries the normal way, e.g. via `metabase.driver/process-query`.
   Not neccesarily bound when using various functions like `fk->` in the REPL."
  nil)

(defn driver-supports?
  "Does the currently bound `*driver*` support FEATURE?
   (This returns `nil` if `*driver*` is unbound. `*driver*` is always bound when running queries the normal way,
   but may not be when calling this function directly from the REPL.)"
  [feature]
  (when *driver*
    ((resolve 'metabase.driver/driver-supports?) *driver* feature)))

;; `assert-driver-supports` doesn't run check when `*driver*` is unbound (e.g., when used in the REPL)
;; Allows flexibility when composing queries for tests or interactive development
(defn assert-driver-supports
  "When `*driver*` is bound, assert that is supports keyword FEATURE."
  [feature]
  (when *driver*
    (when-not (driver-supports? feature)
      (throw (Exception. (str (name feature) " is not supported by this driver."))))))

;; Expansion Happens in a Few Stages:
;; 1. A query dict is parsed via pattern-matching code in the Query Expander.
;;    field IDs and values are replaced with FieldPlaceholders and ValuePlaceholders, respectively.
;; 2. Relevant Fields and Tables are fetched from the DB, and the placeholder objects are "resolved"
;;    and replaced with objects like Field, Value, etc.

;;; # ------------------------------------------------------------ JOINING OBJECTS ------------------------------------------------------------

;; These are just used by the QueryExpander to record information about how joins should occur.

(s/defrecord JoinTableField [field-id   :- su/IntGreaterThanZero
                             field-name :- su/NonBlankString])

(s/defrecord JoinTable [source-field :- JoinTableField
                        pk-field     :- JoinTableField
                        table-id     :- su/IntGreaterThanZero
                        table-name   :- su/NonBlankString
                        schema       :- (s/maybe su/NonBlankString)
                        join-alias   :- su/NonBlankString])

;;; # ------------------------------------------------------------ PROTOCOLS ------------------------------------------------------------

(defprotocol IField
  "Methods specific to the Query Expander `Field` record type."
  (qualified-name-components [this]
    "Return a vector of name components of the form `[table-name parent-names... field-name]`"))


;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
;;; |                                                                             FIELDS                                                                             |
;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+

;; Field is the "expanded" form of a Field ID (field reference) in MBQL
(s/defrecord Field [field-id           :- su/IntGreaterThanZero
                    field-name         :- su/NonBlankString
                    field-display-name :- su/NonBlankString
                    base-type          :- su/FieldType
                    special-type       :- (s/maybe su/FieldType)
                    visibility-type    :- (apply s/enum field/visibility-types)
                    table-id           :- su/IntGreaterThanZero
                    schema-name        :- (s/maybe su/NonBlankString)
                    table-name         :- (s/maybe su/NonBlankString) ; TODO - Why is this `maybe` ?
                    position           :- (s/maybe su/IntGreaterThanZero)
                    fk-field-id        :- (s/maybe s/Int)
                    description        :- (s/maybe su/NonBlankString)
                    parent-id          :- (s/maybe su/IntGreaterThanZero)
                    ;; Field once its resolved; FieldPlaceholder before that
                    parent             :- s/Any]
  clojure.lang.Named
  (getName [_] field-name) ; (name <field>) returns the *unqualified* name of the field, #obvi

  IField
  (qualified-name-components [this]
    (conj (if parent
            (qualified-name-components parent)
            [table-name])
          field-name)))

;;; DateTimeField

(def ^:const datetime-field-units
  "Valid units for a `DateTimeField`."
  #{:default :minute :minute-of-hour :hour :hour-of-day :day :day-of-week :day-of-month :day-of-year
    :week :week-of-year :month :month-of-year :quarter :quarter-of-year :year})

(def ^:const relative-datetime-value-units
  "Valid units for a `RelativeDateTimeValue`."
  #{:minute :hour :day :week :month :quarter :year})

(def DatetimeFieldUnit "Schema for datetime units that are valid for `DateTimeField` forms." (s/named (apply s/enum datetime-field-units)          "Valid datetime unit for a field"))
(def DatetimeValueUnit "Schema for datetime units that valid for relative datetime values."  (s/named (apply s/enum relative-datetime-value-units) "Valid datetime unit for a relative datetime"))

(defn datetime-field-unit?
  "Is UNIT a valid datetime unit for a `DateTimeField` form?"
  [unit]
  (contains? datetime-field-units (keyword unit)))

(defn relative-datetime-value-unit?
  "Is UNIT a valid datetime unit for a `RelativeDateTimeValue` form?"
  [unit]
  (contains? relative-datetime-value-units (keyword unit)))


;; DateTimeField is just a simple wrapper around Field
(s/defrecord DateTimeField [field :- Field
                            unit  :- DatetimeFieldUnit]
  clojure.lang.Named
  (getName [_] (name field)))

(s/defrecord ExpressionRef [expression-name :- su/NonBlankString]
  clojure.lang.Named
  (getName [_] expression-name)
  IField
  (qualified-name-components [_]
    [nil expression-name]))


;;; Placeholder Types

;; Replace Field IDs with these during first pass
(s/defrecord FieldPlaceholder [field-id      :- su/IntGreaterThanZero
                               fk-field-id   :- (s/maybe (s/constrained su/IntGreaterThanZero
                                                                        (fn [_] (or (assert-driver-supports :foreign-keys) true)) ; assert-driver-supports will throw Exception if driver is bound
                                                                        "foreign-keys is not supported by this driver."))         ; and driver does not support foreign keys
                               datetime-unit :- (s/maybe (apply s/enum datetime-field-units))])

(s/defrecord AgFieldRef [index :- s/Int])
;; TODO - add a method to get matching expression from the query?




(def FieldPlaceholderOrExpressionRef
  "Schema for either a `FieldPlaceholder` or `ExpressionRef`."
  (s/named (s/cond-pre FieldPlaceholder ExpressionRef)
           "Valid field or expression reference."))

(s/defrecord RelativeDatetime [amount :- s/Int
                               unit   :- DatetimeValueUnit])

(declare Aggregation AnyField AnyValueLiteral)

(def ^:private ExpressionOperator (s/named (s/enum :+ :- :* :/) "Valid expression operator"))

(s/defrecord Expression [operator   :- ExpressionOperator
                         args       :- [(s/cond-pre (s/recursive #'AnyValueLiteral)
                                                    (s/recursive #'AnyField)
                                                    (s/recursive #'Aggregation))]
                         custom-name :- (s/maybe su/NonBlankString)])


(def AnyField
  "Schema for a anything that is considered a valid 'field'."
  (s/named (s/cond-pre Field
                       FieldPlaceholder
                       AgFieldRef
                       Expression
                       ExpressionRef)
           "AnyField: field, ag field reference, expression, expression reference, or field literal."))


;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
;;; |                                                                             VALUES                                                                             |
;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+

(def LiteralDatetimeString
  "Schema for an MBQL datetime string literal, in ISO-8601 format."
  (s/constrained su/NonBlankString u/date-string? "Valid ISO-8601 datetime string literal"))

(def LiteralDatetime
  "Schema for an MBQL literal datetime value: and ISO-8601 string or `java.sql.Date`."
  (s/named (s/cond-pre java.sql.Date LiteralDatetimeString) "Valid datetime literal (must be ISO-8601 string or java.sql.Date)"))

(def Datetime
  "Schema for an MBQL datetime value: an ISO-8601 string, `java.sql.Date`, or a relative dateitme form."
  (s/named (s/cond-pre RelativeDatetime LiteralDatetime) "Valid datetime (must ISO-8601 string literal or a relative-datetime form)"))

(def OrderableValue
  "Schema for something that is orderable value in MBQL (either a number or datetime)."
  (s/named (s/cond-pre s/Num Datetime) "Valid orderable value (must be number or datetime)"))

(def AnyValueLiteral
  "Schema for anything that is a considered a valid value literal in MBQL - `nil`, a `Boolean`, `Number`, `String`, or relative datetime form."
  (s/named (s/maybe (s/cond-pre s/Bool su/NonBlankString OrderableValue)) "Valid value (must be nil, boolean, number, string, or a relative-datetime form)"))


;; Value is the expansion of a value within a QL clause
;; Information about the associated Field is included for convenience
;; TODO - Value doesn't need the whole field, just the relevant type info / units
(s/defrecord Value [value   :- AnyValueLiteral
                    field   :- (s/recursive #'AnyField)])

;; e.g. an absolute point in time (literal)
(s/defrecord DateTimeValue [value :- Timestamp
                            field :- DateTimeField])

(s/defrecord RelativeDateTimeValue [amount :- s/Int
                                    unit   :- DatetimeValueUnit
                                    field  :- DateTimeField])

(defprotocol ^:private IDateTimeValue
  (unit [this]
    "Get the `unit` associated with a `DateTimeValue` or `RelativeDateTimeValue`.")

  (add-date-time-units [this n]
    "Return a new `DateTimeValue` or `RelativeDateTimeValue` with N `units` added to it."))

(extend-protocol IDateTimeValue
  DateTimeValue
  (unit                [this]   (:unit (:field this)))
  (add-date-time-units [this n] (assoc this :value (u/relative-date (unit this) n (:value this))))

  RelativeDateTimeValue
  (unit                [this]   (:unit this))
  (add-date-time-units [this n] (update this :amount (partial + n))))


;;; Placeholder Types

;; Replace values with these during first pass over Query.
;; Include associated Field ID so appropriate the info can be found during Field resolution
(s/defrecord ValuePlaceholder [field-placeholder :- FieldPlaceholderOrExpressionRef
                               value             :- AnyValueLiteral])

(def OrderableValuePlaceholder
  "`ValuePlaceholder` schema with the additional constraint that the value be orderable (a number or datetime)."
  (s/constrained ValuePlaceholder (comp (complement (s/checker OrderableValue)) :value) ":value must be orderable (number or datetime)"))

(def StringValuePlaceholder
  "`ValuePlaceholder` schema with the additional constraint that the value be a string/"
  (s/constrained ValuePlaceholder (comp string? :value) ":value must be a string"))

(def AnyFieldOrValue
  "Schema that accepts anything normally considered a field (including expressions and literals) *or* a value or value placehoder."
  (s/named (s/cond-pre AnyField Value ValuePlaceholder) "Field or value"))


;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
;;; |                                                                             CLAUSES                                                                            |
;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+

;;; aggregation

(s/defrecord AggregationWithoutField [aggregation-type :- (s/named (s/enum :count :cumulative-count)
                                                                   "Valid aggregation type")
                                      custom-name      :- (s/maybe su/NonBlankString)])

(s/defrecord AggregationWithField [aggregation-type :- (s/named (s/enum :avg :count :cumulative-sum :distinct :max :min :stddev :sum)
                                                                "Valid aggregation type")
                                   field            :- (s/cond-pre FieldPlaceholderOrExpressionRef
                                                                   Expression)
                                   custom-name      :- (s/maybe su/NonBlankString)])

(defn- valid-aggregation-for-driver? [{:keys [aggregation-type]}]
  (when (= aggregation-type :stddev)
    (assert-driver-supports :standard-deviation-aggregations))
  true)

(def Aggregation
  "Schema for an `aggregation` subclause in an MBQL query."
  (s/constrained
   (s/cond-pre AggregationWithField AggregationWithoutField Expression)
   valid-aggregation-for-driver?
   "standard-deviation-aggregations is not supported by this driver."))


;;; filter

(s/defrecord EqualityFilter [filter-type :- (s/enum := :!=)
                             field       :- FieldPlaceholderOrExpressionRef
                             value       :- AnyFieldOrValue])

(s/defrecord ComparisonFilter [filter-type :- (s/enum :< :<= :> :>=)
                               field       :- FieldPlaceholderOrExpressionRef
                               value       :- OrderableValuePlaceholder])

(s/defrecord BetweenFilter [filter-type  :- (s/eq :between)
                            min-val      :- OrderableValuePlaceholder
                            field        :- FieldPlaceholderOrExpressionRef
                            max-val      :- OrderableValuePlaceholder])

(s/defrecord StringFilter [filter-type :- (s/enum :starts-with :contains :ends-with)
                           field       :- FieldPlaceholderOrExpressionRef
                           value       :- StringValuePlaceholder])

(def SimpleFilterClause
  "Schema for a non-compound, non-`not` MBQL `filter` clause."
  (s/named (s/cond-pre EqualityFilter ComparisonFilter BetweenFilter StringFilter)
           "Simple filter clause"))

(s/defrecord NotFilter [compound-type :- (s/eq :not)
                        subclause     :- SimpleFilterClause])

(declare Filter)

(s/defrecord CompoundFilter [compound-type :- (s/enum :and :or)
                             subclauses    :- [(s/recursive #'Filter)]])

(def Filter
  "Schema for top-level `filter` clause in an MBQL query."
  (s/named (s/cond-pre SimpleFilterClause NotFilter CompoundFilter)
           "Valid filter clause"))


;;; order-by

(def OrderByDirection
  "Schema for the direction in an `OrderBy` subclause."
  (s/named (s/enum :ascending :descending) "Valid order-by direction"))

(def OrderBy
  "Schema for top-level `order-by` clause in an MBQL query."
  (s/named {:field     AnyField
            :direction OrderByDirection}
           "Valid order-by subclause"))


;;; page

(def Page
  "Schema for the top-level `page` clause in a MBQL query."
  (s/named {:page  su/IntGreaterThanZero
            :items su/IntGreaterThanZero}
           "Valid page clause"))


;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
;;; |                                                                             QUERY                                                                              |
;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+

(def Query
  "Schema for an MBQL query."
  {(s/optional-key :aggregation) [Aggregation]
   (s/optional-key :breakout)    [FieldPlaceholderOrExpressionRef]
   (s/optional-key :fields)      [AnyField]
   (s/optional-key :filter)      Filter
   (s/optional-key :limit)       su/IntGreaterThanZero
   (s/optional-key :order-by)    [OrderBy]
   (s/optional-key :page)        Page
   (s/optional-key :expressions) {s/Keyword Expression}
   :source-table                 su/IntGreaterThanZero})