diff --git a/.github/workflows/drivers.yml b/.github/workflows/drivers.yml index fdf39b7e06939619b033733ebce00f0594d91412..a7de36c4b7150a074c2a4a9d333893569ad8e2ea 100644 --- a/.github/workflows/drivers.yml +++ b/.github/workflows/drivers.yml @@ -173,7 +173,7 @@ jobs: with: junit-name: 'be-tests-mariadb-latest-ee' - be-tests-mongo-4-0-ee: + be-tests-mongo-4-2-ee: if: github.event.pull_request.draft == false runs-on: ubuntu-20.04 timeout-minutes: 60 @@ -184,17 +184,17 @@ jobs: MB_MONGO_TEST_PASSWORD: metasample123 services: mongodb: - image: metabase/qa-databases:mongo-sample-4.0 + image: metabase/qa-databases:mongo-sample-4.2 ports: - "27017:27017" steps: - uses: actions/checkout@v3 - - name: Test MongoDB driver (4.0) + - name: Test MongoDB driver (4.2) uses: ./.github/actions/test-driver with: - junit-name: 'be-tests-mongo-4-0-ee' + junit-name: 'be-tests-mongo-4-2-ee' - be-tests-mongo-4-0-ssl-ee: + be-tests-mongo-4-2-ssl-ee: if: github.event.pull_request.draft == false runs-on: ubuntu-20.04 timeout-minutes: 60 @@ -207,7 +207,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Spin up Mongo docker container - run: docker run -d -p 27017:27017 --name metamongo metabase/qa-databases:mongo-sample-4.0 mongod --dbpath /data/db2/ --sslMode requireSSL --sslPEMKeyFile /etc/mongo/metamongo.pem --sslCAFile /etc/mongo/metaca.crt + run: docker run -d -p 27017:27017 --name metamongo metabase/qa-databases:mongo-sample-4.2 mongod --dbpath /data/db2/ --sslMode requireSSL --sslPEMKeyFile /etc/mongo/metamongo.pem --sslCAFile /etc/mongo/metaca.crt - name: Wait until the port 27017 is ready run: while ! nc -z localhost 27017; do sleep 1; done timeout-minutes: 5 @@ -222,10 +222,10 @@ jobs: curl https://raw.githubusercontent.com/metabase/metabase-qa/master/dbs/mongo/certificates/metaca.crt \ -o ./test_resources/ssl/mongo/metaca.crt - - name: Test MongoDB SSL driver (4.0) + - name: Test MongoDB SSL driver (4.2) uses: ./.github/actions/test-driver with: - junit-name: 'be-tests-mongo-4-0-ee' + junit-name: 'be-tests-mongo-4-2-ee' be-tests-mongo-5-0-ee: if: github.event.pull_request.draft == false diff --git a/docs/questions/query-builder/expressions-list.md b/docs/questions/query-builder/expressions-list.md index 1b2a0cb5c84d43b6c5899e226293fdd32fbd51bc..05db2e33a001a03d3de992c4920b3fefbbf52a66 100644 --- a/docs/questions/query-builder/expressions-list.md +++ b/docs/questions/query-builder/expressions-list.md @@ -464,7 +464,7 @@ Related: [contains](#contains), [substring](#substring). ### replace -Replaces a part of the input text with new text. +Replaces all occurrences of a search text in the input text with the replacement text. Syntax: `replace(text, find, replace)`. diff --git a/modules/drivers/mongo/src/metabase/driver/mongo.clj b/modules/drivers/mongo/src/metabase/driver/mongo.clj index a0e8d6abe4ed657fbbe3dbd6f2de2c94b1cd9f43..b469797dda808d01746345c693877bebef54cdbf 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo.clj @@ -12,6 +12,7 @@ [metabase.driver.mongo.parameters :as mongo.params] [metabase.driver.mongo.query-processor :as mongo.qp] [metabase.driver.mongo.util :refer [with-mongo-connection]] + [metabase.driver.util :as driver.u] [metabase.models :refer [Field]] [metabase.query-processor.store :as qp.store] [metabase.query-processor.timezone :as qp.timezone] @@ -233,30 +234,24 @@ :standard-deviation-aggregations]] (defmethod driver/supports? [:mongo feature] [_driver _feature] true)) -(defn- db-version [db] - (get-in db [:details :version])) +(defmethod driver/database-supports? [:mongo :expressions] + [_driver _feature db] + (-> (:dbms_version db) + :semantic-version + (driver.u/semantic-version-gte [4 2]))) -(defn- parse-version [version] - (->> (str/split version #"\.") - (take 2) - (map #(Integer/parseInt %)))) - -(defn- db-major-version [db] - (some-> (db-version db) parse-version first)) - -(defmethod driver/database-supports? [:mongo :expressions] [_ _ db] - (let [version (db-major-version db)] - (and (some? version) (>= version 4)))) - -(defmethod driver/database-supports? [:mongo :date-arithmetics] [_ _ db] - (let [version (db-major-version db)] - (and (some? version) (>= version 5)))) +(defmethod driver/database-supports? [:mongo :date-arithmetics] + [_driver _feature db] + (-> (:dbms_version db) + :semantic-version + (driver.u/semantic-version-gte [5]))) (defmethod driver/database-supports? [:mongo :now] ;; The $$NOW aggregation expression was introduced in version 4.2. - [_ _ db] - (let [version (some-> (db-version db) parse-version)] - (and (some? version) (>= (first version) 4) (>= (second version) 2)))) + [_driver _feature db] + (-> (:dbms_version db) + :semantic-version + (driver.u/semantic-version-gte [4 2]))) (defmethod driver/mbql->native :mongo [_ query] @@ -320,16 +315,12 @@ (defmethod driver/table-rows-sample :mongo [_driver table fields rff opts] - (let [mongo-opts {;; setting :truncation-start is needed because of a bug - ;; in our mongo drivers handling of :substring (#27270) - :truncation-start 0 - :limit metadata-queries/nested-field-sample-limit + (let [mongo-opts {:limit metadata-queries/nested-field-sample-limit :order-by [[:desc [:field (get-id-field-id table) nil]]]}] (metadata-queries/table-rows-sample table fields rff (merge mongo-opts opts)))) (comment (require '[clojure.java.io :as io] - '[metabase.driver.util :as driver.u] '[monger.credentials :as mcred]) (import javax.net.ssl.SSLSocketFactory) diff --git a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj index d0b53ac9a31bb12a41918dca2224547e7ab350fc..802f3bbbd270104e4932000882396de2d070d595 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj @@ -8,6 +8,7 @@ [java-time :as t] [metabase.driver :as driver] [metabase.driver.common :as driver.common] + [metabase.driver.util :as driver.u] [metabase.mbql.schema :as mbql.s] [metabase.mbql.util :as mbql.u] [metabase.models.field :refer [Field]] @@ -20,13 +21,14 @@ [metabase.util.date-2 :as u.date] [metabase.util.i18n :refer [tru]] [metabase.util.schema :as su] - [monger.operators :refer [$add $addToSet $and $avg $cond $dayOfMonth $dayOfWeek $dayOfYear $divide $eq + [monger.operators :refer [$add $addToSet $and $avg $cond + $dayOfMonth $dayOfWeek $dayOfYear $divide $eq $group $gt $gte $hour $limit $lt $lte $match $max $min $minute $mod $month $multiply $ne $not $or $project $regex $second $size $skip $sort $strcasecmp $subtract $sum $toLower $year]] [schema.core :as s]) - (:import [org.bson.types ObjectId Binary] - org.bson.BsonBinarySubType)) + (:import org.bson.BsonBinarySubType + [org.bson.types Binary ObjectId])) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Schema | @@ -78,6 +80,9 @@ ;; TODO - We already have a *query* dynamic var in metabase.query-processor.interface. Do we need this one too? (def ^:dynamic ^:private *query* nil) +(defn- get-mongo-version [] + (driver/dbms-version :mongo (qp.store/database))) + (defmulti ^:private ->rvalue "Format this `Field` or value for use as the right hand value of an expression, e.g. by adding `$` to a `Field`'s name" @@ -420,21 +425,62 @@ (defmethod ->rvalue :exp [[_ inp]] {"$exp" (->rvalue inp)}) (defmethod ->rvalue :sqrt [[_ inp]] {"$sqrt" (->rvalue inp)}) -(defmethod ->rvalue :trim [[_ inp]] {"$trim" (->rvalue inp)}) -(defmethod ->rvalue :ltrim [[_ inp]] {"$ltrim" (->rvalue inp)}) -(defmethod ->rvalue :rtrim [[_ inp]] {"$rtrim" (->rvalue inp)}) +(defmethod ->rvalue :trim [[_ inp]] {"$trim" {"input" (->rvalue inp)}}) +(defmethod ->rvalue :ltrim [[_ inp]] {"$ltrim" {"input" (->rvalue inp)}}) +(defmethod ->rvalue :rtrim [[_ inp]] {"$rtrim" {"input" (->rvalue inp)}}) (defmethod ->rvalue :upper [[_ inp]] {"$toUpper" (->rvalue inp)}) (defmethod ->rvalue :lower [[_ inp]] {"$toLower" (->rvalue inp)}) (defmethod ->rvalue :length [[_ inp]] {"$strLenCP" (->rvalue inp)}) (defmethod ->rvalue :power [[_ & args]] {"$pow" (mapv ->rvalue args)}) -(defmethod ->rvalue :replace [[_ & args]] {"$replaceAll" (mapv ->rvalue args)}) (defmethod ->rvalue :concat [[_ & args]] {"$concat" (mapv ->rvalue args)}) -(defmethod ->rvalue :substring [[_ & args]] {"$substrCP" (mapv ->rvalue args)}) - (defmethod ->rvalue :temporal-extract [[_ inp unit]] (with-rvalue-temporal-bucketing (->rvalue inp) unit)) +(defmethod ->rvalue :replace + [[_ & args]] + (let [version (get-mongo-version)] + (if (driver.u/semantic-version-gte (:semantic-version version) [4 4]) + (let [[expr fnd replacement] (mapv ->rvalue args)] + {"$replaceAll" {"input" expr "find" fnd "replacement" replacement}}) + (throw (ex-info "Replace requires MongoDB 4.4 or above" + {:database-version version}))))) + +(defmethod ->rvalue :substring + [[_ & [expr idx cnt]]] + (let [expr-val (->rvalue expr) + idx-val {"$subtract" [(->rvalue idx) 1]}] + {"$substrCP" [expr-val + idx-val + ;; The last argument is not optional in mongo + (if (some? cnt) + (->rvalue cnt) + {"$subtract" [{"$strLenCP" expr-val} idx-val]})]})) + +(defmethod ->rvalue :/ + [[_ & [_ & divisors :as args]]] + ;; division works outside in (/ 1 2 3) => (/ (/ 1 2) 3) + (let [division (reduce + (fn [accum head] + {"$divide" [accum head]}) + (map ->rvalue args)) + literal-zero? (some #(and (number? %) (zero? %)) divisors) + non-literal-nil-checks (mapv (fn [divisor] {"$eq" [(->rvalue divisor) 0]}) (remove number? divisors))] + (cond + literal-zero? + nil + + (empty? non-literal-nil-checks) + division + + (= 1 (count non-literal-nil-checks)) + {"$cond" [(first non-literal-nil-checks) nil + division]} + + :else + {"$cond" [{"$or" non-literal-nil-checks} nil + division]}))) + ;;; Intervals are not first class Mongo citizens, so they cannot be translated on their own. ;;; The only thing we can do with them is adding to or subtracting from a date valued expression. ;;; Also, date arithmetic with intervals was first implemented in version 5. (Before that only @@ -443,9 +489,6 @@ ;;; Because of this, whenever we translate date arithmetic with intervals, we check the major ;;; version of the database and throw a nice exception if it's less than 5. -(defn- get-mongo-version [] - (driver/dbms-version :mongo (qp.store/database))) - (defn- check-date-operations-supported [] (let [{mongo-version :version, [major-version] :semantic-version} (get-mongo-version)] (when (and major-version (< major-version 5)) @@ -494,7 +537,6 @@ {"$subtract" (mapv ->rvalue args)})) (defmethod ->rvalue :* [[_ & args]] {"$multiply" (mapv ->rvalue args)}) -(defmethod ->rvalue :/ [[_ & args]] {"$divide" (mapv ->rvalue args)}) (defmethod ->rvalue :coalesce [[_ & args]] {"$ifNull" (mapv ->rvalue args)}) @@ -677,6 +719,10 @@ ;;; -------------------------------------------------- aggregation --------------------------------------------------- +(def ^:private aggregation-op + "The set of operators handled by [[aggregation->rvalue]] and [[expand-aggregation]]." + #{:avg :count :count-where :distinct :max :min :share :stddev :sum :sum-where :var}) + (defmethod ->rvalue :case [[_ cases options]] {:$switch {:branches (for [[pred expr] cases] {:case (compile-cond pred) @@ -685,8 +731,8 @@ (defn- aggregation->rvalue [ag] (mbql.u/match-one ag - [:aggregation-options ag _] - (recur ag) + [:aggregation-options ag' _] + (recur ag') [:count] {$sum 1} @@ -715,7 +761,7 @@ :else (throw (ex-info (tru "Don''t know how to handle aggregation {0}" ag) - {:type :invalid-query, :clause ag})))) + {:type :invalid-query, :clause ag})))) (defn- unwrap-named-ag [[ag-type arg :as ag]] (if (= ag-type :aggregation-options) @@ -755,41 +801,101 @@ (defmethod expand-aggregation :share [[_ pred :as ag]] - (let [count-where-name (name (gensym "count-where-")) - count-name (name (gensym "count-")) + (let [count-where-expr (name (gensym "$count-where-")) + count-expr (name (gensym "$count-")) pred (if (= (first pred) :share) (second pred) pred)] - {:group {count-where-name (aggregation->rvalue [:count-where pred]) - count-name (aggregation->rvalue [:count])} - :post {(annotate/aggregation-name ag) {$divide [(str "$" count-where-name) (str "$" count-name)]}}})) + {:group {(subs count-where-expr 1) (aggregation->rvalue [:count-where pred]) + (subs count-expr 1) (aggregation->rvalue [:count])} + :post [{(annotate/aggregation-name ag) {$divide [count-where-expr count-expr]}}]})) ;; MongoDB doesn't have a variance operator, but you calculate it by taking the square of the standard deviation. ;; However, `$pow` is not allowed in the `$group` stage. So calculate standard deviation in the (defmethod expand-aggregation :var [ag] (let [[_ expr] (unwrap-named-ag ag) - stddev-name (name (gensym "stddev-"))] - {:group {stddev-name (aggregation->rvalue [:stddev expr])} - :post {(annotate/aggregation-name ag) {:$pow [(str \$ stddev-name) 2]}}})) + stddev-expr (name (gensym "$stddev-"))] + {:group {(subs stddev-expr 1) (aggregation->rvalue [:stddev expr])} + :post [{(annotate/aggregation-name ag) {:$pow [stddev-expr 2]}}]})) (defmethod expand-aggregation :default [ag] {:group {(annotate/aggregation-name ag) (aggregation->rvalue ag)}}) +(defn- extract-aggregation + "Separate the expression `aggregation` named `aggr-name` into two parts: + an simple expression and an aggregation expression, where the simple expression + references the result of the aggregation expression such that first evaluating + the aggregation expression and binding its result to `aggr-name` and then + evaluating the simple expression in this context, the result is the same as + evaluating the whole expression `aggregation`. + This separation is necessary, because MongoDB doesn't support embedding + aggregations in `normal' expressions. + + For example the aggregation + [:aggregation-options + [:+ [:/ [:sum + [:case [[[:< [:field 12 nil] [:field 7 nil]] + [:field 12 nil]]] + {:default 0}]] + 2] + 1] + {:name \"expression\"}] + is transformed into the simple expression + [:+ [:/ \"$expression\" 2] 1] + and the aggregation expression + [:aggregation-options + [:sum + [:case [[[:< [:field 12 nil] [:field 7 nil]] + [:field 12 nil]]] + {:default 0}]] + {:name \"expression\"}]" + [aggregation aggr-name] + (when (and (vector? aggregation) (seq aggregation)) + (let [[op & args] aggregation] + (cond + (= op :aggregation-options) + (let [[embedding-expr aggregation'] (extract-aggregation (first args) aggr-name)] + [embedding-expr (into [:aggregation-options aggregation'] (rest args))]) + + (aggregation-op op) + [(str \$ aggr-name) aggregation] + + :else + (let [ges (map #(extract-aggregation % aggr-name) args) + [embedding-expr aggregation'] (first (filter some? ges))] + (when-not aggregation' + (throw + (ex-info (tru "Don''t know how to handle aggregation {0}" aggregation) + {:type :invalid-query, :clause aggregation}))) + [(into [op] (map (fn [arg ge] (if ge embedding-expr arg)) args ges)) + aggregation']))))) + +(defn- expand-embedded-aggregation [aggregation] + (let [aggr-name (annotate/aggregation-name aggregation) + [embedding-expr aggregation-expr] (extract-aggregation aggregation aggr-name) + expanded (expand-aggregation aggregation-expr)] + (cond-> expanded + (not (string? embedding-expr)) + (update :post conj {aggr-name (->rvalue embedding-expr)})))) + (defn- group-and-post-aggregations "Mongo is picky about which top-level aggregations it allows with groups. Eg. even though [:/ [:count-if ...] [:count]] is a perfectly fine reduction, it's not allowed. Therefore more complex aggregations are split in two: the reductions are done in `$group` stage after which - we do postprocessing in `$addFields` stage to arrive at the final result. The intermittent results - accrued in `$group` stage are discarded in the final `$project` stage." + we do postprocessing in `$addFields` stage to arrive at the final result. + The groups are assumed to be independent an collapsed into a single stage, but separate + `$addFields` stages are created for post processing so that stages can refer to the results + of preceding stages. + The intermittent results accrued in `$group` stage are discarded in the final `$project` stage." [id aggregations] - (let [expanded-ags (map expand-aggregation aggregations) + (let [expanded-ags (map expand-embedded-aggregation aggregations) group-ags (mapcat :group expanded-ags) post-ags (mapcat :post expanded-ags)] - [{$group (into (ordered-map/ordered-map "_id" id) group-ags)} - (when (not-empty post-ags) - {:$addFields (into (ordered-map/ordered-map) post-ags)})])) + (into [{$group (into (ordered-map/ordered-map "_id" id) group-ags)}] + (map (fn [p] {:$addFields p})) + post-ags))) (defn- projection-group-map [fields] (reduce @@ -855,10 +961,6 @@ :asc 1 :desc -1)]))}) -(defn- handle-order-by [{:keys [order-by]} pipeline-ctx] - (cond-> pipeline-ctx - (seq order-by) (update :query conj (order-by->$sort order-by)))) - ;;; ----------------------------------------------------- fields ----------------------------------------------------- (defn- remove-parent-fields @@ -869,8 +971,9 @@ To preserve the previous behavior, we will include only the child fields (since the parent field always appears first in the projection/field order list, and that is the stated behavior according to the link above)." [fields] - (let [parent->child-id (reduce (fn [acc [_ field-id & _]] - (if (integer? field-id) + (let [parent->child-id (reduce (fn [acc [agg-type field-id & _]] + (if (and (= agg-type :field) + (integer? field-id)) (let [field (qp.store/field field-id)] (if-let [parent-id (:parent_id field)] (update acc parent-id conj (u/the-id field)) @@ -882,6 +985,20 @@ (and (integer? field-id) (contains? parent->child-id field-id))) fields))) +(defn- handle-order-by [{:keys [order-by breakout]} pipeline-ctx] + (let [breakout-fields (set breakout) + sort-fields (for [field (remove-parent-fields (map second order-by)) + ;; We only care about expressions not added as breakout + :when (and (not (contains? breakout-fields field)) + (= :expression ((.dispatchFn ^clojure.lang.MultiFn ->rvalue) field)))] + [(->lvalue field) (->rvalue field)])] + (cond-> pipeline-ctx + (seq sort-fields) (update :query conj + ;; We $addFields before sorting, otherwise expressions will not be available for the sort + {:$addFields (into (ordered-map/ordered-map) sort-fields)}) + (seq order-by) (update :query conj + (order-by->$sort order-by))))) + (defn- handle-fields [{:keys [fields]} pipeline-ctx] (if-not (seq fields) pipeline-ctx diff --git a/modules/drivers/mongo/test/metabase/driver/mongo_test.clj b/modules/drivers/mongo/test/metabase/driver/mongo_test.clj index 38362514ba22af1b6a63c35cd018d020cdb06683..4ca657bf7dc675cd3be26d41f5aaa261d062ba42 100644 --- a/modules/drivers/mongo/test/metabase/driver/mongo_test.clj +++ b/modules/drivers/mongo/test/metabase/driver/mongo_test.clj @@ -75,26 +75,18 @@ (deftest database-supports?-test (mt/test-driver :mongo - (doseq [{:keys [details expected]} [{:details {:host "localhost" - :port 3000 - :user "metabase" - :pass "metasample123" - :dbname "bad-db-name" - :version "5.0.0"} - :expected true} - {:details {} - :expected false} - {:details {:version nil} - :expected false} - {:details {:host "localhost" - :port 27017 - :dbname "metabase-test" - :version "2.2134234.lol"} - :expected false}] - :let [ssl-details (tdm/conn-details details)]] - (testing (str "connect with " details) + (doseq [{:keys [dbms_version expected]} + [{:dbms_version {:semantic-version [5 0 0 0]} + :expected true} + {:dbms_version {} + :expected false} + {:dbms_version {:semantic-version []} + :expected false} + {:dbms_version {:semantic-version [2 2134234]} + :expected false}]] + (testing (str "supports with " dbms_version) (is (= expected - (let [db (db/insert! Database {:name "dummy", :engine "mongo", :details ssl-details})] + (let [db (db/insert! Database {:name "dummy", :engine "mongo", :dbms_version dbms_version})] (driver/database-supports? :mongo :expressions db)))))))) diff --git a/src/metabase/db/metadata_queries.clj b/src/metabase/db/metadata_queries.clj index 34470646941566fc34acc33d8ee2fe2ddaaed6b8..3291f361caa93d05c11f84e42ee87ac5fe609ee9 100644 --- a/src/metabase/db/metadata_queries.clj +++ b/src/metabase/db/metadata_queries.clj @@ -89,8 +89,7 @@ (def TableRowsSampleOptions "Schema for `table-rows-sample` options" - (s/maybe {(s/optional-key :truncation-start) s/Int - (s/optional-key :truncation-size) s/Int + (s/maybe {(s/optional-key :truncation-size) s/Int (s/optional-key :limit) s/Int (s/optional-key :order-by) (helpers/distinct (helpers/non-empty [mbql.s/OrderBy])) (s/optional-key :rff) s/Any})) @@ -108,9 +107,7 @@ "Returns the mbql query to query a table for sample rows" [table fields - {:keys [truncation-start truncation-size limit order-by] - :or {truncation-start 1, limit max-sample-rows} - :as _opts}] + {:keys [truncation-size limit order-by] :or {limit max-sample-rows} :as _opts}] (let [database (table/database table) driver (driver.u/database->driver database) text-fields (filter text-field? fields) @@ -118,7 +115,7 @@ (into {} (for [field text-fields] [field [(str (gensym "substring")) [:substring [:field (u/the-id field) nil] - truncation-start truncation-size]]])))] + 1 truncation-size]]])))] {:database (:db_id table) :type :query :query (cond-> {:source-table (u/the-id table) diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index be6178f1a404cdf53e20b462869770c2d0b656fe..4e8acb97c0676a97dbd38934c333d958261b2b41 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -515,9 +515,8 @@ (e.g., :left-join is not supported by any version of Mongo DB). In some cases, a feature may only be supported by certain versions of the database engine. - In this case, after implementing `:version` in `describe-database` for the driver, - you can check in `(get-in db [:details :version])` and determine - whether a feature is supported for this particular database. + In this case, after implementing `[[dbms-version]]` for your driver + you can determine whether a feature is supported for this particular database. (database-supports? :mongo :set-timezone mongo-db) ; -> true" {:arglists '([driver feature database]), :added "0.41.0"} diff --git a/src/metabase/driver/util.clj b/src/metabase/driver/util.clj index 8f0932398dace00464a0612aa2c58580a4668b76..f77157b155516461ac4760f46d13aee1a745283a 100644 --- a/src/metabase/driver/util.clj +++ b/src/metabase/driver/util.clj @@ -13,6 +13,8 @@ [metabase.query-processor.error-type :as qp.error-type] [metabase.util :as u] [metabase.util.i18n :refer [deferred-tru trs]] + [metabase.util.schema :as su] + [schema.core :as s] [toucan.db :as db]) (:import (java.io ByteArrayInputStream) @@ -160,16 +162,6 @@ (log/error e (trs "Failed to connect to database")) false)))) -(defn report-timezone-if-supported - "Returns the report-timezone if `driver` supports setting it's timezone and a report-timezone has been specified by - the user." - [driver] - (when (driver/supports? driver :set-timezone) - (let [report-tz (driver/report-timezone)] - (when (seq report-tz) - report-tz)))) - - ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Driver Resolution | ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -212,6 +204,25 @@ :when (driver/available? driver)] driver))) +(s/defn semantic-version-gte + "Returns true if xv is greater than or equal to yv according to semantic versioning. + xv and yv are sequences of integers of the form `[major minor ...]`, where only + major is obligatory. + Examples: + (semantic-version-gte [4 1] [4 1]) => true + (semantic-version-gte [4 0 1] [4 1]) => false + (semantic-version-gte [4 1] [4]) => true + (semantic-version-gte [3 1] [4]) => false" + [xv :- [su/NonNegativeInt] yv :- [su/NonNegativeInt]] :- s/Bool + (loop [xv (seq xv), yv (seq yv)] + (or (nil? yv) + (let [[x & xs] xv + [y & ys] yv + x (if (nil? x) 0 x) + y (if (nil? y) 0 y)] + (or (> x y) + (and (>= x y) (recur xs ys))))))) + (defn- file-upload-props [{prop-name :name, visible-if :visible-if, disp-nm :display-name, :as conn-prop}] (if (premium-features/is-hosted?) [(-> (assoc conn-prop diff --git a/src/metabase/query_processor/store.clj b/src/metabase/query_processor/store.clj index 46cb7f5001f836578e2e53fdfc536203d7bfe3d7..5148d29bf047232af73d9dd296e523a256ada26c 100644 --- a/src/metabase/query_processor/store.clj +++ b/src/metabase/query_processor/store.clj @@ -57,6 +57,7 @@ [:id :engine :name + :dbms_version :details :settings]) diff --git a/test/metabase/driver/util_test.clj b/test/metabase/driver/util_test.clj index a51ab747067b26e1696fb45c3cbb6317650ba831..6708f7b6d395d1ae7fa675d29c2509a50b321712 100644 --- a/test/metabase/driver/util_test.clj +++ b/test/metabase/driver/util_test.clj @@ -235,3 +235,20 @@ (select-keys transformed [:host :password-value :keystore-password-value :use-keystore]))) ;; the keystore-value should have been base64 decoded because of treat-before-posting being base64 (see above) (is (mt/secret-value-equals? ks-val (:keystore-value transformed)))))) + +(deftest semantic-version-gte-test + (testing "semantic-version-gte works as expected" + (is (true? (driver.u/semantic-version-gte [5 0] [4 0]))) + (is (true? (driver.u/semantic-version-gte [5 0 1] [4 0]))) + (is (true? (driver.u/semantic-version-gte [5 0] [4 0 1]))) + (is (true? (driver.u/semantic-version-gte [5 0] [4 1]))) + (is (true? (driver.u/semantic-version-gte [4 1] [4 1]))) + (is (true? (driver.u/semantic-version-gte [4 1] [4]))) + (is (true? (driver.u/semantic-version-gte [4] [4]))) + (is (true? (driver.u/semantic-version-gte [4] [4 0 0]))) + (is (false? (driver.u/semantic-version-gte [3] [4]))) + (is (false? (driver.u/semantic-version-gte [4] [4 1]))) + (is (false? (driver.u/semantic-version-gte [4 0] [4 0 1]))) + (is (false? (driver.u/semantic-version-gte [4 0 1] [4 1]))) + (is (false? (driver.u/semantic-version-gte [3 9] [4 0]))) + (is (false? (driver.u/semantic-version-gte [3 1] [4]))))) diff --git a/test/metabase/query_processor_test/advanced_math_test.clj b/test/metabase/query_processor_test/advanced_math_test.clj index 77996b65c77ff3972bfa8a79c32b51b76c3bbd98..e8b475b34921ab86ea80410db7c04c9b06579060 100644 --- a/test/metabase/query_processor_test/advanced_math_test.clj +++ b/test/metabase/query_processor_test/advanced_math_test.clj @@ -2,6 +2,7 @@ (:require [clojure.test :refer :all] [metabase.driver :as driver] + [metabase.driver.util :as driver.u] [metabase.test :as mt] [metabase.util :as u])) @@ -21,7 +22,13 @@ (deftest test-round (mt/test-drivers (mt/normal-drivers-with-feature :expressions) - (is (= 1.0 (test-math-expression [:round 0.7]))))) + (if (or (not= driver/*driver* :mongo) + ;; mongo supports $round since version 4.2 + (driver.u/semantic-version-gte + (-> (mt/db) :dbms_version :semantic-version) + [4 2])) + (is (= 1.0 (test-math-expression [:round 0.7]))) + (is (= 0 0))))) (deftest test-floor (mt/test-drivers (mt/normal-drivers-with-feature :expressions) diff --git a/test/metabase/query_processor_test/date_time_zone_functions_test.clj b/test/metabase/query_processor_test/date_time_zone_functions_test.clj index 0f65da4581d8a10f486bd9abd7b215824ddae5fe..158a28892c319ac41b9df3fa6e352b2290d1ef84 100644 --- a/test/metabase/query_processor_test/date_time_zone_functions_test.clj +++ b/test/metabase/query_processor_test/date_time_zone_functions_test.clj @@ -226,7 +226,7 @@ (is (= expected (test-temporal-extract query)))))))) (deftest temporal-extraction-with-datetime-arithmetic-expression-tests - (mt/test-drivers (mt/normal-drivers-with-feature :temporal-extract :expressions) + (mt/test-drivers (mt/normal-drivers-with-feature :temporal-extract :expressions :date-arithmetics) (mt/dataset times-mixed (doseq [{:keys [title expected query]} [{:title "Nested interval addition expression" diff --git a/test/metabase/query_processor_test/expressions_test.clj b/test/metabase/query_processor_test/expressions_test.clj index b8db4092a85c8b652d7015bde7edd609750bb549..68c329858475d4d782089984a7d0de7f6e5ed030 100644 --- a/test/metabase/query_processor_test/expressions_test.clj +++ b/test/metabase/query_processor_test/expressions_test.clj @@ -130,7 +130,16 @@ (mt/run-mbql-query venues {:expressions {:x [:+ $price $id]} :limit 3 - :order-by [[:desc [:expression :x]]]}))))))) + :order-by [[:desc [:expression :x]]]}))))) + (testing "Can we refer to expressions inside an ORDER BY clause with a secondary order by?" + (is (= [[81 "Tanoshi Sushi & Sake Bar" 40 40.7677 -73.9533 4 85.0] + [79 "Sushi Yasuda" 40 40.7514 -73.9736 4 83.0] + [77 "Sushi Nakazawa" 40 40.7318 -74.0045 4 81.0]] + (mt/formatted-rows [int str int 4.0 4.0 int float] + (mt/run-mbql-query venues + {:expressions {:x [:+ $price $id]} + :limit 3 + :order-by [[:desc $price] [:desc [:expression :x]]]}))))))) (deftest aggregate-breakout-expression-test (mt/test-drivers (mt/normal-drivers-with-feature :expressions) @@ -195,12 +204,11 @@ (is (= [[nil] [0.0] [0.0] [10.0] [8.0] [5.0] [5.0] [nil] [0.0] [0.0]] (calculate-bird-scarcity $count)))) - (testing (str "do expressions automatically handle division by zero? Should return `nil` in the results for places " - "where that was attempted") - (is (= [[nil] [nil] [10.0] [12.5] [20.0] [20.0] [nil] [nil] [9.09] [7.14]] - (calculate-bird-scarcity [:/ 100.0 $count] - [:!= $count nil])))) - + (testing (str "do expressions automatically handle division by zero? Should return `nil` " + "in the results for places where that was attempted") + (is (= [[nil] [nil] [10.0] [12.5] [20.0] [20.0] [nil] [nil] [9.09] [7.14]] + (calculate-bird-scarcity [:/ 100.0 $count] + [:!= $count nil])))) (testing (str "do expressions handle division by `nil`? Should return `nil` in the results for places where that " "was attempted") @@ -211,16 +219,20 @@ [:!= $count 0]])))) (testing "can we handle BOTH NULLS AND ZEROES AT THE SAME TIME????" - (is (= [[nil] [nil] [nil] [10.0] [12.5] [20.0] [20.0] [nil] [nil] [nil]] - (calculate-bird-scarcity [:/ 100.0 $count])))) + (is (= [[nil] [nil] [nil] [10.0] [12.5] [20.0] [20.0] [nil] [nil] [nil]] + (calculate-bird-scarcity [:/ 100.0 $count])))) + + (testing "can we handle dividing by literal 0?" + (is (= [[nil] [nil] [nil] [nil] [nil] [nil] [nil] [nil] [nil] [nil]] + (calculate-bird-scarcity [:/ $count 0])))) (testing "ok, what if we use multiple args to divide, and more than one is zero?" - (is (= [[nil] [nil] [nil] [1.0] [1.56] [4.0] [4.0] [nil] [nil] [nil]] - (calculate-bird-scarcity [:/ 100.0 $count $count])))) + (is (= [[nil] [nil] [nil] [1.0] [1.56] [4.0] [4.0] [nil] [nil] [nil]] + (calculate-bird-scarcity [:/ 100.0 $count $count])))) (testing "are nulls/zeroes still handled appropriately when nested inside other expressions?" - (is (= [[nil] [nil] [nil] [20.0] [25.0] [40.0] [40.0] [nil] [nil] [nil]] - (calculate-bird-scarcity [:* [:/ 100.0 $count] 2])))) + (is (= [[nil] [nil] [nil] [20.0] [25.0] [40.0] [40.0] [nil] [nil] [nil]] + (calculate-bird-scarcity [:* [:/ 100.0 $count] 2])))) (testing (str "if a zero is present in the NUMERATOR we should return ZERO and not NULL " "(`0 / 10 = 0`; `10 / 0 = NULL`, at least as far as MBQL is concerned)") @@ -235,7 +247,6 @@ (is (= [[nil] [10.0] [10.0] [0.0] [2.0] [5.0] [5.0] [nil] [10.0] [10.0]] (calculate-bird-scarcity [:- 10 $count])))) - (testing "can multiplications handle nulls & zeros?" (is (= [[nil] [0.0] [0.0] [10.0] [8.0] [5.0] [5.0] [nil] [0.0] [0.0]] (calculate-bird-scarcity [:* 1 $count])))))) @@ -255,7 +266,7 @@ [(format-fn (u.date/parse s "UTC"))]))) (deftest temporal-arithmetic-test - (mt/test-drivers (mt/normal-drivers-with-feature :expressions) + (mt/test-drivers (mt/normal-drivers-with-feature :expressions :date-arithmetics) (testing "Test that we can do datetime arithemtics using MBQL `:interval` clause in expressions" (is (= (robust-dates ["2014-09-02T13:45:00" @@ -377,7 +388,9 @@ :order-by [[:asc $id]]}))))))) (deftest expression-using-aggregation-test - (mt/test-drivers (mt/normal-drivers-with-feature :expressions) + (mt/test-drivers (disj (mt/normal-drivers-with-feature :expressions) + ;; The limit in source-query is ignored (#27249) + :mongo) (testing "Can we use aggregations from previous steps in expressions (#12762)" (is (= [["20th Century Cafe" 2 2 0] [ "25°" 2 2 0] diff --git a/test/metabase/query_processor_test/filter_test.clj b/test/metabase/query_processor_test/filter_test.clj index ac00ccdc0a426755c5592f3a2ee645fb5dc02bdf..e59b9d0e46d6f94d28a441a72904d6f501ea0e34 100644 --- a/test/metabase/query_processor_test/filter_test.clj +++ b/test/metabase/query_processor_test/filter_test.clj @@ -112,25 +112,17 @@ {:aggregation [[:count]] :filter [:between !day.date "2015-04-01" "2015-05-01"]})))))))) -(defn- mongo-major-version [db] - (when (= driver/*driver* :mongo) - (-> (driver/dbms-version :mongo db) :semantic-version first))) - (defn- timezone-arithmetic-drivers [] (set/intersection - ;; we also want to test this against MongoDB but [[mt/normal-drivers-with-feature]] would normally not include that - ;; since MongoDB only supports expressions if version is 4.0 or above and [[mt/normal-drivers-with-feature]] - ;; currently uses [[driver/supports?]] rather than [[driver/database-supports?]] (TODO FIXME, see #23422) - (conj (mt/normal-drivers-with-feature :expressions) :mongo) - (timezones-test/timezone-aware-column-drivers))) + (mt/normal-drivers-with-feature :expressions) + (mt/normal-drivers-with-feature :date-arithmetics) + (timezones-test/timezone-aware-column-drivers))) (deftest temporal-arithmetic-test (testing "Should be able to use temporal arithmetic expressions in filters (#22531)" (mt/test-drivers (timezone-arithmetic-drivers) (mt/dataset attempted-murders - (when-not (some-> (mongo-major-version (mt/db)) - (< 5)) - (doseq [offset-unit [:year :day] + (doseq [offset-unit [:year :day] interval-unit [:year :day] compare-op [:between := :< :<= :> :>=] compare-order (cond-> [:field-first] @@ -154,15 +146,13 @@ (if (= driver/*driver* :mongo) (is (or (nil? result) (pos-int? result))) - (is (nat-int? result)))))))))))) + (is (nat-int? result))))))))))) (deftest nonstandard-temporal-arithmetic-test (testing "Nonstandard temporal arithmetic should also be supported" (mt/test-drivers (timezone-arithmetic-drivers) (mt/dataset attempted-murders - (when-not (some-> (mongo-major-version (mt/db)) - (< 5)) - (doseq [offset-unit [:year :day] + (doseq [offset-unit [:year :day] interval-unit [:year :day] compare-op [:between := :< :<= :> :>=] add-op [:+ #_:-] ; TODO support subtraction like sql.qp/add-interval-honeysql-form (#23423) @@ -191,7 +181,7 @@ (if (= driver/*driver* :mongo) (is (or (nil? result) (pos-int? result))) - (is (nat-int? result)))))))))))) + (is (nat-int? result))))))))))) (deftest or-test (mt/test-drivers (mt/normal-drivers) diff --git a/test/metabase/query_processor_test/string_extracts_test.clj b/test/metabase/query_processor_test/string_extracts_test.clj index f0eff63e8cbbb0ecdc77c1cb62239d5e665810ca..401514b8bef53d491a83a7ad102f63b7116770e1 100644 --- a/test/metabase/query_processor_test/string_extracts_test.clj +++ b/test/metabase/query_processor_test/string_extracts_test.clj @@ -1,6 +1,8 @@ (ns metabase.query-processor-test.string-extracts-test (:require [clojure.test :refer :all] + [metabase.driver :as driver] + [metabase.driver.util :as driver.u] [metabase.query-processor :as qp] [metabase.test :as mt] [metabase.test.data :as data])) @@ -47,11 +49,24 @@ (is (= "Red" (test-string-extract [:substring [:field (data/id :venues :name) nil] 1 3]))) (is (= "ed Medicine" (test-string-extract [:substring [:field (data/id :venues :name) nil] 2]))) (is (= "Red Medicin" (test-string-extract [:substring [:field (data/id :venues :name) nil] - 1 [:- [:length [:field (data/id :venues :name) nil]] 1]]))))) + 1 [:- [:length [:field (data/id :venues :name) nil]] 1]]))) + (is (= "ne" (test-string-extract [:substring [:field (data/id :venues :name) nil] + [:- [:length [:field (data/id :venues :name) nil]] 1]]))))) (deftest test-replace (mt/test-drivers (mt/normal-drivers-with-feature :expressions) - (is (= "Red Baloon" (test-string-extract [:replace [:field (data/id :venues :name) nil] "Medicine" "Baloon"]))))) + (when + (or (not= driver/*driver* :mongo) + ;; mongo supports $replaceAll since version 4.4 + (driver.u/semantic-version-gte + (-> (mt/db) :dbms_version :semantic-version) + [4 4])) + (is (= "Red Baloon" (test-string-extract [:replace [:field (data/id :venues :name) nil] "Medicine" "Baloon"]))) + (is (= "Rod Modicino" (test-string-extract [:replace [:field (data/id :venues :name) nil] "e" "o"]))) + (is (= "Red" (test-string-extract [:replace [:field (data/id :venues :name) nil] " Medicine" ""]))) + (is (= "Larry's The Prime Rib" (test-string-extract + [:replace [:field (data/id :venues :name) nil] "Lawry's" "Larry's"] + [:= [:field (data/id :venues :name) nil] "Lawry's The Prime Rib"])))))) (deftest test-coalesce (mt/test-drivers (mt/normal-drivers-with-feature :expressions) @@ -84,13 +99,6 @@ (mt/formatted-rows [identity int]) first))))) -(deftest replace-escaping-test - (mt/test-drivers - (mt/normal-drivers-with-feature :expressions) - (is (= "Larry's The Prime Rib" (test-string-extract - [:replace [:field (data/id :venues :name) nil] "Lawry's" "Larry's"] - [:= [:field (data/id :venues :name) nil] "Lawry's The Prime Rib"]))))) - (deftest regex-match-first-escaping-test (mt/test-drivers (mt/normal-drivers-with-feature :expressions :regex)