From d0be9fc4c291438204e792f3981bf7fe5a42878a Mon Sep 17 00:00:00 2001
From: Ngoc Khuat <qn.khuat@gmail.com>
Date: Thu, 29 Sep 2022 14:11:09 +0700
Subject: [PATCH] DateAdd and DateSubtract expression (#25414)

---
 .../src/metabase/lib/expressions/config.js    |  12 ++
 .../lib/expressions/helper-text-strings.ts    |  41 ++++++
 .../custom-column/cc-data-type.cy.spec.js     |   5 +
 .../bigquery_cloud_sdk/query_processor.clj    |   2 +-
 .../mongo/src/metabase/driver/mongo.clj       |  18 ++-
 .../metabase/driver/mongo/query_processor.clj |  11 ++
 .../driver/mongo/query_processor_test.clj     |  51 ++++++++
 .../vertica/src/metabase/driver/vertica.clj   |  12 +-
 shared/src/metabase/mbql/normalize.cljc       |   8 ++
 shared/src/metabase/mbql/schema.cljc          |  28 ++++-
 shared/test/metabase/mbql/normalize_test.cljc |  20 +--
 src/metabase/driver.clj                       |   7 +-
 src/metabase/driver/sql/query_processor.clj   |   8 ++
 src/metabase/util/date_2.clj                  |   4 +-
 .../middleware/annotate_test.clj              |  36 +++---
 .../date_time_zone_functions_test.clj         | 118 +++++++++++++++++-
 16 files changed, 339 insertions(+), 42 deletions(-)

diff --git a/frontend/src/metabase/lib/expressions/config.js b/frontend/src/metabase/lib/expressions/config.js
index a952ecac3af..66f40ae8221 100644
--- a/frontend/src/metabase/lib/expressions/config.js
+++ b/frontend/src/metabase/lib/expressions/config.js
@@ -361,6 +361,16 @@ export const MBQL_CLAUSES = {
     type: "number",
     args: ["expression"],
   },
+  "date-add": {
+    displayName: `dateAdd`,
+    type: "number",
+    args: ["expression", "number", "string"],
+  },
+  "date-subtract": {
+    displayName: `dateSubtract`,
+    type: "number",
+    args: ["expression", "number", "string"],
+  },
 };
 
 for (const [name, clause] of Object.entries(MBQL_CLAUSES)) {
@@ -441,6 +451,8 @@ export const EXPRESSION_FUNCTIONS = new Set([
   "get-hour",
   "get-minute",
   "get-second",
+  "date-add",
+  "date-subtract",
   // boolean
   "contains",
   "ends-with",
diff --git a/frontend/src/metabase/lib/expressions/helper-text-strings.ts b/frontend/src/metabase/lib/expressions/helper-text-strings.ts
index 3580e154594..a45981820c6 100644
--- a/frontend/src/metabase/lib/expressions/helper-text-strings.ts
+++ b/frontend/src/metabase/lib/expressions/helper-text-strings.ts
@@ -667,6 +667,47 @@ const helperTextStrings: HelpText[] = [
       },
     ],
   },
+  {
+    name: "date-add",
+    structure: "dateAdd(" + t`column` + ", " + t`amount` + ", " + t`unit` + ")",
+    description: t`Adds some units of time to a date or timestamp value.`,
+    example: "dateAdd([" + t`Created At` + '], 1, "' + t`month` + '")',
+    args: [
+      {
+        name: t`column`,
+        description: t`The column with your date or timestamp values.`,
+      },
+      {
+        name: t`amount`,
+        description: t`The number of units to be added.`,
+      },
+      {
+        name: t`unit`,
+        description: t`"year", "month", "quarter", "day", "hour", "minute", "second" or "millisecond".`,
+      },
+    ],
+  },
+  {
+    name: "date-subtract",
+    structure:
+      "dateSubtract(" + t`column` + ", " + t`amount` + ", " + t`unit` + ")",
+    description: t`Subtracts some units of time to a date or timestamp value.`,
+    example: "dateSubtract([" + t`Created At` + '], 1, "' + t`month` + '")',
+    args: [
+      {
+        name: t`column`,
+        description: t`The column with your date or timestamp values.`,
+      },
+      {
+        name: t`amount`,
+        description: t`The number of units to be subtracted.`,
+      },
+      {
+        name: t`unit`,
+        description: t`"year", "month", "quarter", "day", "hour", "minute", "second" or "millisecond".`,
+      },
+    ],
+  },
 ];
 
 export const getHelpText = (name: string): HelpText | undefined => {
diff --git a/frontend/test/metabase/scenarios/custom-column/cc-data-type.cy.spec.js b/frontend/test/metabase/scenarios/custom-column/cc-data-type.cy.spec.js
index f3e70629074..9b92de27421 100644
--- a/frontend/test/metabase/scenarios/custom-column/cc-data-type.cy.spec.js
+++ b/frontend/test/metabase/scenarios/custom-column/cc-data-type.cy.spec.js
@@ -49,6 +49,11 @@ describe("scenarios > question > custom column > data type", () => {
       { name: "Hour", formula: "hour([Created At])" },
       { name: "Minute", formula: "minute([Created At])" },
       { name: "Second", formula: "second([Created At])" },
+      { name: "Date Add", formula: 'dateAdd([Created At], 1, "month")' },
+      {
+        name: "Date Subtract",
+        formula: 'dateSubtract([Created At], 1, "month")',
+      },
     ]);
 
     visualize();
diff --git a/modules/drivers/bigquery-cloud-sdk/src/metabase/driver/bigquery_cloud_sdk/query_processor.clj b/modules/drivers/bigquery-cloud-sdk/src/metabase/driver/bigquery_cloud_sdk/query_processor.clj
index 3461503c7a4..f7665059669 100644
--- a/modules/drivers/bigquery-cloud-sdk/src/metabase/driver/bigquery_cloud_sdk/query_processor.clj
+++ b/modules/drivers/bigquery-cloud-sdk/src/metabase/driver/bigquery_cloud_sdk/query_processor.clj
@@ -727,7 +727,7 @@
 
 (defmethod sql.qp/cast-temporal-string [:bigquery-cloud-sdk :Coercion/ISO8601->DateTime]
   [_driver _semantic_type expr]
-  (hx/->timestamp expr))
+  (hx/->datetime expr))
 
 (defmethod sql.qp/cast-temporal-string [:bigquery-cloud-sdk :Coercion/ISO8601->Date]
   [_driver _semantic_type expr]
diff --git a/modules/drivers/mongo/src/metabase/driver/mongo.clj b/modules/drivers/mongo/src/metabase/driver/mongo.clj
index 84c511e066e..3f113b0fe9c 100644
--- a/modules/drivers/mongo/src/metabase/driver/mongo.clj
+++ b/modules/drivers/mongo/src/metabase/driver/mongo.clj
@@ -221,12 +221,20 @@
                  :native-parameters]]
   (defmethod driver/supports? [:mongo feature] [_ _] true))
 
+(defn- db-major-version
+  [db]
+  (some-> (get-in db [:details :version])
+          (str/split #"\.")
+          first
+          Integer/parseInt))
+
 (defmethod driver/database-supports? [:mongo :expressions] [_ _ db]
-  (let [version (some-> (get-in db [:details :version])
-                        (str/split #"\.")
-                        first
-                        Integer/parseInt)]
-    (and (some? version) (<= 4 version))))
+  (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/mbql->native :mongo
   [_ query]
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 ef8a4aba035..e5a86863fc8 100644
--- a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj
+++ b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj
@@ -455,6 +455,17 @@
 
 (defmethod ->rvalue :coalesce [[_ & args]] {"$ifNull" (mapv ->rvalue args)})
 
+(defmethod ->rvalue :date-add        [[_ inp amount unit]] (do
+                                                             (check-date-operations-supported)
+                                                             {"$dateAdd" {:startDate (->rvalue inp)
+                                                                          :unit      unit
+                                                                          :amount    amount}}))
+(defmethod ->rvalue :date-subtract   [[_ inp amount unit]] (do
+                                                             (check-date-operations-supported)
+                                                             {"$dateSubtract" {:startDate (->rvalue inp)
+                                                                               :unit      unit
+                                                                               :amount    amount}}))
+
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                               CLAUSE APPLICATION                                               |
 ;;; +----------------------------------------------------------------------------------------------------------------+
diff --git a/modules/drivers/mongo/test/metabase/driver/mongo/query_processor_test.clj b/modules/drivers/mongo/test/metabase/driver/mongo/query_processor_test.clj
index 4e1d72e05f4..a81ad8cb25c 100644
--- a/modules/drivers/mongo/test/metabase/driver/mongo/query_processor_test.clj
+++ b/modules/drivers/mongo/test/metabase/driver/mongo/query_processor_test.clj
@@ -2,9 +2,11 @@
   (:require [clojure.set :as set]
             [clojure.test :refer :all]
             [java-time :as t]
+            [metabase.driver :as driver]
             [metabase.driver.mongo.query-processor :as mongo.qp]
             [metabase.models :refer [Field Table]]
             [metabase.query-processor :as qp]
+            [metabase.query-processor-test.date-time-zone-functions-test :as qp.datetime-test]
             [metabase.query-processor.timezone :as qp.timezone]
             [metabase.test :as mt]
             [metabase.util :as u]
@@ -346,3 +348,52 @@
                                                        [:interval 1 :year]
                                                        [:field "date-field"]]
                                                       [:absolute-datetime (t/local-date "2008-05-31")]]))))))
+
+(deftest date-math-tests
+  (mt/test-driver :mongo
+    (mt/dataset qp.datetime-test/times-mixed
+      ;; date arithmetic doesn't supports until mongo 5+
+      (when (driver/database-supports? :mongo :date-arithmetics (mt/db))
+        (testing "date arithmetic with datetime columns"
+          (let [[col-type field-id] [:datetime (mt/id :times :dt)]]
+            (doseq [op               [:date-add :date-subtract]
+                    unit             [:year :quarter :month :day :hour :minute :second :millisecond]
+                    {:keys [expected query]}
+                    [{:expected [(qp.datetime-test/date-math op #t "2004-03-19 09:19:09" 2 unit col-type)
+                                 (qp.datetime-test/date-math op #t "2008-06-20 10:20:10" 2 unit col-type)
+                                 (qp.datetime-test/date-math op #t "2012-11-21 11:21:11" 2 unit col-type)
+                                 (qp.datetime-test/date-math op #t "2012-11-21 11:21:11" 2 unit col-type)]
+                      :query    {:expressions {"expr" [op [:field field-id nil] 2 unit]}
+                                 :fields      [[:expression "expr"]]}}
+                     {:expected (into [] (frequencies
+                                          [(qp.datetime-test/date-math op #t "2004-03-19 09:19:09" 2 unit col-type)
+                                           (qp.datetime-test/date-math op #t "2008-06-20 10:20:10" 2 unit col-type)
+                                           (qp.datetime-test/date-math op #t "2012-11-21 11:21:11" 2 unit col-type)
+                                           (qp.datetime-test/date-math op #t "2012-11-21 11:21:11" 2 unit col-type)]))
+                      :query    {:expressions {"expr" [op [:field field-id nil] 2 unit]}
+                                 :aggregation [[:count]]
+                                 :breakout    [[:expression "expr"]]}}]]
+              (testing (format "%s %s function works as expected on %s column for driver %s" op unit col-type driver/*driver*)
+                (is (= (set expected) (set (qp.datetime-test/test-date-math query))))))))
+
+        (testing "date arithmetic with date columns"
+          (let [[col-type field-id] [:date (mt/id :times :d)]]
+            (doseq [op               [:date-add :date-subtract]
+                    unit             [:year :quarter :month :day]
+                    {:keys [expected query]}
+                    [{:expected [(qp.datetime-test/date-math op #t "2004-03-19 00:00:00" 2 unit col-type)
+                                 (qp.datetime-test/date-math op #t "2008-06-20 00:00:00" 2 unit col-type)
+                                 (qp.datetime-test/date-math op #t "2012-11-21 00:00:00" 2 unit col-type)
+                                 (qp.datetime-test/date-math op #t "2012-11-21 00:00:00" 2 unit col-type)]
+                       :query   {:expressions {"expr" [op [:field field-id nil] 2 unit]}
+                                 :fields      [[:expression "expr"]]}}
+                     {:expected (into [] (frequencies
+                                           [(qp.datetime-test/date-math op #t "2004-03-19 00:00:00" 2 unit col-type)
+                                            (qp.datetime-test/date-math op #t "2008-06-20 00:00:00" 2 unit col-type)
+                                            (qp.datetime-test/date-math op #t "2012-11-21 00:00:00" 2 unit col-type)
+                                            (qp.datetime-test/date-math op #t "2012-11-21 00:00:00" 2 unit col-type)]))
+                      :query    {:expressions {"expr" [op [:field field-id nil] 2 unit]}
+                                 :aggregation [[:count]]
+                                 :breakout    [[:expression "expr"]]}}]]
+              (testing (format "%s %s function works as expected on %s column for driver %s" op unit col-type driver/*driver*)
+                (is (= (set expected) (set (qp.datetime-test/test-date-math query))))))))))))
diff --git a/modules/drivers/vertica/src/metabase/driver/vertica.clj b/modules/drivers/vertica/src/metabase/driver/vertica.clj
index 7488b3d2170..c756a52be5a 100644
--- a/modules/drivers/vertica/src/metabase/driver/vertica.clj
+++ b/modules/drivers/vertica/src/metabase/driver/vertica.clj
@@ -123,11 +123,17 @@
 
 (defmethod sql.qp/add-interval-honeysql-form :vertica
   [_ hsql-form amount unit]
+  (hsql/call :timestampadd unit)
+  ;; using `timestampadd` instead of `+ (INTERVAL)` because vertica add inteval for month, or year
+  ;; by adding the equivalent number of days, not adding the unit compoinent.
+  ;; For example `select date '2004-02-02' + interval '1 year' will return `2005-02-01` because it's adding
+  ;; 365 days under the hood and 2004 is a leap year. Whereas other dbs will return `2006-02-02`.
+  ;; So we use timestampadd to make the behavior consistent with other dbs
   (let [acceptable-types (case unit
-                           (:millisecond :second :minute :hour) #{"time" "timetz" "timestamp" "timestamptz"}
-                           (:day :week :month :quarter :year)   #{"date" "timestamp" "timestamptz"})
+                          (:millisecond :second :minute :hour) #{"time" "timetz" "timestamp" "timestamptz"}
+                          (:day :week :month :quarter :year)   #{"date" "timestamp" "timestamptz"})
         hsql-form        (hx/cast-unless-type-in "timestamp" acceptable-types hsql-form)]
-    (hx/+ hsql-form (hsql/raw (format "(INTERVAL '%d %s')" (int amount) (name unit))))))
+   (hsql/call :timestampadd unit amount hsql-form)))
 
 (defn- materialized-views
   "Fetch the Materialized Views for a Vertica `database`.
diff --git a/shared/src/metabase/mbql/normalize.cljc b/shared/src/metabase/mbql/normalize.cljc
index ebb400e2c24..0fce777080f 100644
--- a/shared/src/metabase/mbql/normalize.cljc
+++ b/shared/src/metabase/mbql/normalize.cljc
@@ -149,6 +149,14 @@
   [[_ amount unit]]
   [:interval amount (maybe-normalize-token unit)])
 
+(defmethod normalize-mbql-clause-tokens :date-add
+  [[_ field amount unit]]
+  [:date-add (normalize-tokens field :ignore-path) amount (maybe-normalize-token unit)])
+
+(defmethod normalize-mbql-clause-tokens :date-subtract
+  [[_ field amount unit]]
+  [:date-subtract (normalize-tokens field :ignore-path) amount (maybe-normalize-token unit)])
+
 (defmethod normalize-mbql-clause-tokens :temporal-extract
   [[_ field unit]]
   [:temporal-extract (normalize-tokens field :ignore-path) (maybe-normalize-token unit)])
diff --git a/shared/src/metabase/mbql/schema.cljc b/shared/src/metabase/mbql/schema.cljc
index 2ab06654bfc..3bf972118cf 100644
--- a/shared/src/metabase/mbql/schema.cljc
+++ b/shared/src/metabase/mbql/schema.cljc
@@ -475,9 +475,13 @@
     ;; SUGAR drivers do not need to implement
     :get-year :get-quarter :get-month :get-day :get-day-of-week :get-hour :get-minute :get-second})
 
+(def date-arithmetic-functions
+  "Functions to do math with date, datetime."
+  #{:date-add :date-subtract})
+
 (def date+time+timezone-functions
   "Date, time, and timezone related functions."
-  (set/union temporal-extract-functions))
+  (set/union temporal-extract-functions date-arithmetic-functions))
 
 (declare ArithmeticExpression)
 (declare BooleanExpression)
@@ -512,6 +516,10 @@
     (partial is-clause? :value)
     value
 
+    ;; Recursively doing date math
+    (partial is-clause? date-arithmetic-functions)
+    (s/recursive #'DatetimeExpression)
+
     :else
     Field))
 
@@ -651,8 +659,23 @@
 (defclause ^{:requires-features #{:temporal-extract}} ^:sugar get-second
   datetime DateTimeExpressionArg)
 
+(def ^:private ArithmeticDateTimeUnit
+  (s/named
+   (apply s/enum #{:millisecond :second :minute :hour :day :week :month :quarter :year})
+   "arithmetic-datetime-unit"))
+
+(defclause ^{:requires-features #{:date-arithmetics}} date-add
+  datetime DateTimeExpressionArg
+  amount   NumericExpressionArg
+  unit     ArithmeticDateTimeUnit)
+
+(defclause ^{:requires-features #{:date-arithmetics}} date-subtract
+  datetime DateTimeExpressionArg
+  amount   NumericExpressionArg
+  unit     ArithmeticDateTimeUnit)
+
 (def ^:private DatetimeExpression*
-  (one-of temporal-extract
+  (one-of temporal-extract date-add date-subtract
           ;; SUGAR drivers do not need to implement
           get-year get-quarter get-month get-day get-day-of-week get-hour
           get-minute get-second))
@@ -915,7 +938,6 @@
             ;; SUGAR clauses
             cum-count count)))
 
-
 (def ^:private UnnamedAggregation
   (s/recursive #'UnnamedAggregation*))
 
diff --git a/shared/test/metabase/mbql/normalize_test.cljc b/shared/test/metabase/mbql/normalize_test.cljc
index 78a49a78cc8..376e3d49087 100644
--- a/shared/test/metabase/mbql/normalize_test.cljc
+++ b/shared/test/metabase/mbql/normalize_test.cljc
@@ -565,7 +565,13 @@
     {:query {:expressions {"prev_month" [:+ [:field-id 13] [:interval -1 :month]]}}}
 
     {:query {:expressions {:prev_month ["-" ["field-id" 13] ["interval" 1 "month"] ["interval" 1 "day"]]}}}
-    {:query {:expressions {"prev_month" [:- [:field-id 13] [:interval 1 :month] [:interval 1 :day]]}}}}
+    {:query {:expressions {"prev_month" [:- [:field-id 13] [:interval 1 :month] [:interval 1 :day]]}}}
+
+    {:query {:expressions {:date-add ["date-add" ["field" 1 nil] 1 "month"]}}}
+    {:query {:expressions {"date-add" [:date-add [:field 1 nil] 1 :month]}}}
+
+    {:query {:expressions {:date-subtract ["date-subtract" ["field" 1 nil] 1 "month"]}}}
+    {:query {:expressions {"date-subtract" [:date-subtract [:field 1 nil] 1 :month]}}}}
 
    "expressions handle namespaced keywords correctly"
    {{:query {"expressions" {:abc/def ["+" 1 2]}
@@ -1179,12 +1185,12 @@
              (mbql.normalize/normalize
               {:database 1
                :native {:template-tags {"name" {:id "1f56330b-3dcb-75a3-8f3d-5c2c2792b749"
-                                               :name "name"
-                                               :display-name "Name"
-                                               :type "dimension"
-                                               :dimension ["field" 14 nil]
-                                               :widget-type "string/="
-                                               :default ["Hudson Borer"]}}
+                                                :name "name"
+                                                :display-name "Name"
+                                                :type "dimension"
+                                                :dimension ["field" 14 nil]
+                                                :widget-type "string/="
+                                                :default ["Hudson Borer"]}}
                         :query "select * from PEOPLE where {{name}}"}
                :type "native"
                :parameters []})))))
diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj
index 5f37b241735..d20e028b924 100644
--- a/src/metabase/driver.clj
+++ b/src/metabase/driver.clj
@@ -436,10 +436,14 @@
     ;; Does the driver support percentile calculations (including median)
     :percentile-aggregations
 
-    ;; Does the driver support date, time, and timezone manipulation functions?
+    ;; Does the driver support date extraction functions? (i.e get year component of a datetime column)
     ;; DEFAULTS TO TRUE
     :temporal-extract
 
+    ;; Does the driver support doing math with datetime? (i.e Adding 1 year to a datetime column)
+    ;; DEFAULTS TO TRUE
+    :date-arithmetics
+
     ;; Does the driver support experimental "writeback" actions like "delete this row" or "insert a new row" from 44+?
     :actions
 
@@ -466,6 +470,7 @@
 
 (defmethod supports? [::driver :basic-aggregations] [_ _] true)
 (defmethod supports? [::driver :case-sensitivity-string-filter-options] [_ _] true)
+(defmethod supports? [::driver :date-arithmetics] [_ _] true)
 (defmethod supports? [::driver :temporal-extract] [_ _] true)
 
 (defmulti database-supports?
diff --git a/src/metabase/driver/sql/query_processor.clj b/src/metabase/driver/sql/query_processor.clj
index de69ec71f9c..43d9a3aba87 100644
--- a/src/metabase/driver/sql/query_processor.clj
+++ b/src/metabase/driver/sql/query_processor.clj
@@ -590,6 +590,14 @@
   [driver [_ arg unit]]
   (date driver (temporal-extract-unit->date-unit unit) (->honeysql driver arg)))
 
+(defmethod ->honeysql [:sql :date-add]
+  [driver [_ arg amount unit]]
+  (add-interval-honeysql-form driver (->honeysql driver arg) amount unit))
+
+(defmethod ->honeysql [:sql :date-subtract]
+  [driver [_ arg amount unit]]
+  (add-interval-honeysql-form driver (->honeysql driver arg) (- amount) unit))
+
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                            Field Aliases (AS Forms)                                            |
 ;;; +----------------------------------------------------------------------------------------------------------------+
diff --git a/src/metabase/util/date_2.clj b/src/metabase/util/date_2.clj
index 98a4c396122..120a81bbb6f 100644
--- a/src/metabase/util/date_2.clj
+++ b/src/metabase/util/date_2.clj
@@ -186,8 +186,8 @@
                  :quarter     (t/months (* amount 3))
                  :year        (t/years amount))))))
 
-;; TIMEZONE FIXME - we should add `:millisecond-of-second` (or `:fraction-of-second`?) and `:second-of-minute` as
-;; well. Not sure where we'd use these, but we should have them for consistency
+;; TIMEZONE FIXME - we should add `:millisecond-of-second` (or `:fraction-of-second`?) .
+;; Not sure where we'd use these, but we should have them for consistency
 (def extract-units
   "Units which return a (numerical, periodic) component of a date"
   #{:second-of-minute
diff --git a/test/metabase/query_processor/middleware/annotate_test.clj b/test/metabase/query_processor/middleware/annotate_test.clj
index 9a27c4afe6c..12c2d2c9204 100644
--- a/test/metabase/query_processor/middleware/annotate_test.clj
+++ b/test/metabase/query_processor/middleware/annotate_test.clj
@@ -209,25 +209,25 @@
   (testing "For fields with parents we should return them with a combined name including parent's name"
     (tt/with-temp* [Field [parent {:name "parent", :table_id (mt/id :venues)}]
                     Field [child  {:name "child", :table_id (mt/id :venues), :parent_id (u/the-id parent)}]]
-    (mt/with-everything-store
+     (mt/with-everything-store
         (is (= {:description     nil
-                :table_id        (mt/id :venues)
-                :semantic_type   nil
-                :effective_type  nil
-                ;; these two are a gross symptom. there's some tension. sometimes it makes sense to have an effective
-                ;; type: the db type is different and we have a way to convert. Othertimes, it doesn't make sense:
-                ;; when the info is inferred. the solution to this might be quite extensive renaming
-                :coercion_strategy nil
-                :name            "parent.child"
-                :settings        nil
-                :field_ref       [:field (u/the-id child) nil]
-                :nfc_path        nil
-                :parent_id       (u/the-id parent)
-                :id              (u/the-id child)
-                :visibility_type :normal
-                :display_name    "Child"
-                :fingerprint     nil
-                :base_type       :type/Text}
+                 :table_id        (mt/id :venues)
+                 :semantic_type   nil
+                 :effective_type  nil
+                 ;; these two are a gross symptom. there's some tension. sometimes it makes sense to have an effective
+                 ;; type: the db type is different and we have a way to convert. Othertimes, it doesn't make sense:
+                 ;; when the info is inferred. the solution to this might be quite extensive renaming
+                 :coercion_strategy nil
+                 :name            "parent.child"
+                 :settings        nil
+                 :field_ref       [:field (u/the-id child) nil]
+                 :nfc_path        nil
+                 :parent_id       (u/the-id parent)
+                 :id              (u/the-id child)
+                 :visibility_type :normal
+                 :display_name    "Child"
+                 :fingerprint     nil
+                 :base_type       :type/Text}
                (into {} (#'annotate/col-info-for-field-clause {} [:field (u/the-id child) nil])))))))
 
   (testing "nested-nested fields should include grandparent name (etc)"
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 943425e0727..8f1979b2f69 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
@@ -1,9 +1,15 @@
 (ns metabase.query-processor-test.date-time-zone-functions-test
-  (:require [clojure.test :refer :all]
+  (:require [clojure.string :as str]
+            [clojure.test :refer :all]
+            [java-time :as t]
             [metabase.driver :as driver]
             [metabase.test :as mt]
             [metabase.util.date-2 :as u.date]))
 
+;;; +----------------------------------------------------------------------------------------------------------------+
+;;; |                                                Date extract tests                                              |
+;;; +----------------------------------------------------------------------------------------------------------------+
+
 (defn test-temporal-extract
   [{:keys [aggregation breakout expressions fields filter limit]}]
   (if breakout
@@ -90,7 +96,6 @@
         (testing (format "extract %s function works as expected on %s column for driver %s" op col-type driver/*driver*)
           (is (= (set (expected-fn op)) (set (test-temporal-extract (query-fn op field-id)))))))))
 
-    ;; need to have seperate tests for mongo because it doesn't have supports for casting yet
     (mt/test-driver :mongo
       (testing "with datetimes columns"
         (let [[col-type field-id] [:datetime (mt/id :times :dt)]]
@@ -142,3 +147,112 @@
                            :fields [[:field (mt/id :times :index) nil]]}}]]
         (testing title
           (is (= expected (test-temporal-extract query))))))))
+
+;;; +----------------------------------------------------------------------------------------------------------------+
+;;; |                                              Date arithmetics tests                                            |
+;;; +----------------------------------------------------------------------------------------------------------------+
+
+(defn date-math
+  [op x amount unit col-type]
+  (let [amount (if (= op :date-add)
+                 amount
+                 (- amount))
+        fmt    (cond
+                 ;; the :date column of :presto should have this format too,
+                 ;; but the test data we created for presto is datetime even if we define it as date
+                 (and (= driver/*driver* :presto) (#{:text-as-date} col-type))
+                 "yyyy-MM-dd"
+
+                 (= unit :millisecond)
+                 "yyyy-MM-dd HH:mm:ss.SSS"
+
+                 :else
+                 "yyyy-MM-dd HH:mm:ss")]
+    (t/format fmt (u.date/add x unit amount))))
+
+(defn- normalize-timestamp-str [x]
+  (if (number? x)
+    (int x)
+    (-> x
+        (str/replace  #"T" " ")
+        (str/replace  #"Z" ""))))
+
+(defn test-date-math
+  [{:keys [aggregation breakout expressions fields filter limit]}]
+  (if breakout
+    (->> (mt/run-mbql-query times {:expressions expressions
+                                   :aggregation aggregation
+                                   :limit       limit
+                                   :filter      filter
+                                   :breakout    breakout})
+         (mt/formatted-rows [normalize-timestamp-str normalize-timestamp-str]))
+    (->> (mt/run-mbql-query times {:expressions expressions
+                                   :aggregation aggregation
+                                   :limit       limit
+                                   :filter      filter
+                                   :fields      fields})
+         (mt/formatted-rows [normalize-timestamp-str])
+         (map first))))
+
+(deftest date-math-tests
+  (mt/dataset times-mixed
+    ;; mongo doesn't supports coercion yet so we exclude it here, Tests for it are in [[metabase.driver.mongo.query-processor-test]]
+    (mt/test-drivers (disj (mt/normal-drivers-with-feature :date-arithmetics) :mongo)
+      (testing "date arithmetic with datetime columns"
+        (doseq [[col-type field-id] [[:datetime (mt/id :times :dt)] [:text-as-datetime (mt/id :times :as_dt)]]
+                op                  [:date-add :date-subtract]
+                unit                [:year :quarter :month :day :hour :minute :second]
+
+                {:keys [expected query]}
+                [{:expected [(date-math op #t "2004-03-19 09:19:09" 2 unit col-type) (date-math op #t "2008-06-20 10:20:10" 2 unit col-type)
+                             (date-math op #t "2012-11-21 11:21:11" 2 unit col-type) (date-math op #t "2012-11-21 11:21:11" 2 unit col-type)]
+                  :query    {:expressions {"expr" [op [:field field-id nil] 2 unit]}
+                             :fields      [[:expression "expr"]]}}
+                 {:expected (into [] (frequencies
+                                       [(date-math op #t "2004-03-19 09:19:09" 2 unit col-type) (date-math op #t "2008-06-20 10:20:10" 2 unit col-type)
+                                        (date-math op #t "2012-11-21 11:21:11" 2 unit col-type) (date-math op #t "2012-11-21 11:21:11" 2 unit col-type)]))
+                  :query    {:expressions {"expr" [op [:field field-id nil] 2 unit]}
+                             :aggregation [[:count]]
+                             :breakout    [[:expression "expr"]]}}]]
+          (testing (format "%s %s function works as expected on %s column for driver %s" op unit col-type driver/*driver*)
+            (is (= (set expected) (set (test-date-math query)))))))
+
+      (testing "date arithmetic with datetime columns"
+        (doseq [[col-type field-id] [[:date (mt/id :times :d)] [:text-as-date (mt/id :times :as_d)]]
+                op                  [:date-add :date-subtract]
+                unit                [:year :quarter :month :day]
+
+                {:keys [expected query]}
+                [{:expected [(date-math op #t "2004-03-19 00:00:00" 2 unit col-type) (date-math op #t "2008-06-20 00:00:00" 2 unit col-type)
+                             (date-math op #t "2012-11-21 00:00:00" 2 unit col-type) (date-math op #t "2012-11-21 00:00:00" 2 unit col-type)]
+                  :query    {:expressions {"expr" [op [:field field-id nil] 2 unit]}
+                             :fields      [[:expression "expr"]]}}
+                 {:expected (into [] (frequencies
+                                       [(date-math op #t "2004-03-19 00:00:00" 2 unit col-type) (date-math op #t "2008-06-20 00:00:00" 2 unit col-type)
+                                        (date-math op #t "2012-11-21 00:00:00" 2 unit col-type) (date-math op #t "2012-11-21 00:00:00" 2 unit col-type)]))
+                  :query    {:expressions {"expr" [op [:field field-id nil] 2 unit]}
+                             :aggregation [[:count]]
+                             :breakout    [[:expression "expr"]]}}]]
+          (testing (format "%s %s function works as expected on %s column for driver %s" op unit col-type driver/*driver*)
+            (is (= (set expected) (set (test-date-math query))))))))))
+
+(deftest date-math-with-extract-test
+  (mt/test-drivers (mt/normal-drivers-with-feature :date-arithmetics)
+    (mt/dataset times-mixed
+      (doseq [{:keys [title expected query]}
+              [{:title    "Nested date math then extract"
+                :expected [2006 2010 2014]
+                :query    {:expressions {"expr" [:get-year [:date-add [:field (mt/id :times :dt) nil] 2 :year]]}
+                            :fields [[:expression "expr"]]}}
+
+               {:title   "Nested date math twice"
+                :expected ["2006-05-19 09:19:09" "2010-08-20 10:20:10" "2015-01-21 11:21:11"]
+                :query    {:expressions {"expr" [:date-add [:date-add [:field (mt/id :times :dt) nil] 2 :year] 2 :month]}
+                           :fields [[:expression "expr"]]}}
+
+               {:title    "filter with date math"
+                :expected [1]
+                :query   {:filter [:= [:get-year [:date-add [:field (mt/id :times :dt) nil] 2 :year]] 2006]
+                          :fields [[:field (mt/id :times :index)]]}}]]
+        (testing title
+          (is (= (set expected) (set (test-date-math query)))))))))
-- 
GitLab