From dcc0b77998585c108672880434e6b93258e0f008 Mon Sep 17 00:00:00 2001
From: Cam Saul <1455846+camsaul@users.noreply.github.com>
Date: Fri, 6 May 2022 09:43:21 -0700
Subject: [PATCH] Fix :Postgres :day-of-week extracts coming back as 0..6
 instead of 1..7 (#22167)

* Fix :Postgres :day-of-week extracts coming back as 0..6 instead of 1..7

* Test Tuesday-Saturday per @dpsutton suggestion and fix issues with those as well :cry:

* Fix MongoDB for Tuesday
---
 .../metabase/driver/mongo/query_processor.clj | 12 ++++++---
 src/metabase/driver.clj                       |  3 ++-
 src/metabase/driver/common.clj                | 27 ++++++++++++++-----
 src/metabase/driver/postgres.clj              | 10 +++++--
 src/metabase/driver/sql/query_processor.clj   | 24 ++++++++++++-----
 .../date_bucketing_test.clj                   | 18 +++++++++++++
 6 files changed, 75 insertions(+), 19 deletions(-)

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 5278e18eb97..5aa4b8f21ac 100644
--- a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj
+++ b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj
@@ -157,11 +157,17 @@
             (name id-or-name))
     temporal-unit (with-lvalue-temporal-bucketing temporal-unit)))
 
+(defn- add-start-of-week-offset [expr offset]
+  (cond
+    (zero? offset) expr
+    (neg? offset)  (recur expr (+ offset 7))
+    :else          {$mod [{$add [expr offset]}
+                          7]}))
+
 (defn- day-of-week
   [column]
-  (mongo-let [day_of_week {$mod [{$add [{$dayOfWeek column}
-                                        (driver.common/start-of-week-offset :mongo)]}
-                                 7]}]
+  (mongo-let [day_of_week (add-start-of-week-offset {$dayOfWeek column}
+                                                    (driver.common/start-of-week-offset :mongo))]
     {$cond {:if   {$eq [day_of_week 0]}
             :then 7
             :else day_of_week}}))
diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj
index c6d34f3927b..ba7ae9d66bf 100644
--- a/src/metabase/driver.clj
+++ b/src/metabase/driver.clj
@@ -659,8 +659,9 @@
 
 (defmethod default-field-order ::driver [_] :database)
 
+;; TODO -- this can vary based on session variables or connection options
 (defmulti db-start-of-week
-  "Return start of week for given database"
+  "Return the day that is considered to be the start of week by `driver`. Should return a keyword such as `:sunday`."
   {:added "0.37.0" :arglists '([driver])}
   dispatch-on-initialized-driver
   :hierarchy #'hierarchy)
diff --git a/src/metabase/driver/common.clj b/src/metabase/driver/common.clj
index 793fffd7e77..75e19acc79f 100644
--- a/src/metabase/driver/common.clj
+++ b/src/metabase/driver/common.clj
@@ -18,7 +18,8 @@
            org.joda.time.format.DateTimeFormatter))
 
 (def connection-error-messages
-  "Generic error messages that drivers should return in their implementation of `humanize-connection-error-message`."
+  "Generic error messages that drivers should return in their implementation
+  of [[metabase.driver/humanize-connection-error-message]]."
   {:cannot-connect-check-host-and-port
    (str (deferred-tru "Hmm, we couldn''t connect to the database.")
         " "
@@ -443,12 +444,26 @@
   []
   (.indexOf days-of-week (setting/get-value-of-type :keyword :start-of-week)))
 
-(s/defn start-of-week-offset :- s/Int
-  "Return the offset for start of week to have the week start on [[metabase.public-settings/start-of-week]] given
-  `driver`."
-  [driver]
-  (let [db-start-of-week     (.indexOf days-of-week (driver/db-start-of-week driver))
+(defn start-of-week-offset-for-day
+  "Like [[start-of-week-offset]] but takes a `start-of-week` keyword like `:sunday` rather than ` driver`. Returns the
+  offset (as a negative number) needed to adjust a day of week in the range 1..7 with `start-of-week` as one to a day
+  of week in the range 1..7 with [[metabase.public-settings/start-of-week]] as 1."
+  [start-of-week]
+  (let [db-start-of-week     (.indexOf days-of-week start-of-week)
         target-start-of-week (start-of-week->int)
         delta                (int (- target-start-of-week db-start-of-week))]
     (* (Integer/signum delta)
        (- 7 (Math/abs delta)))))
+
+(s/defn start-of-week-offset :- s/Int
+  "Return the offset needed to adjust a day of the week (in the range 1..7) returned by the `driver`, with `1`
+  corresponding to [[driver/db-start-of-week]], so that `1` corresponds to [[metabase.public-settings/start-of-week]] in
+  results.
+
+  e.g.
+
+  If `:my-driver` returns [[driver/db-start-of-week]] as `:sunday` (1 is Sunday, 2 is Monday, and so forth),
+  and [[metabase.public-settings/start-of-week]] is `:monday` (the results should have 1 as Monday, 2 as Tuesday... 7 is
+  Sunday), then the offset should be `-1`, because `:monday` returned by the driver (`2`) minus `1` = `1`."
+  [driver]
+  (start-of-week-offset-for-day (driver/db-start-of-week driver)))
diff --git a/src/metabase/driver/postgres.clj b/src/metabase/driver/postgres.clj
index d69cdb45485..6b2396db90c 100644
--- a/src/metabase/driver/postgres.clj
+++ b/src/metabase/driver/postgres.clj
@@ -247,8 +247,14 @@
 (defmethod sql.qp/date [:postgres :year]            [_ _ expr] (date-trunc :year expr))
 
 (defmethod sql.qp/date [:postgres :day-of-week]
-  [_ _ expr]
-  (sql.qp/adjust-day-of-week :postgres (extract-integer :dow expr)))
+  [_ driver expr]
+  ;; Postgres extract(dow ...) returns Sunday(0)...Saturday(6)
+  ;;
+  ;; Since that's different than what we normally consider the [[metabase.driver/db-start-of-week]] for Postgres
+  ;; (Monday) we need to pass in a custom offset here
+  (sql.qp/adjust-day-of-week driver
+                             (hx/+ (extract-integer :dow expr) 1)
+                             (driver.common/start-of-week-offset-for-day :sunday)))
 
 (defmethod sql.qp/date [:postgres :week]
   [_ _ expr]
diff --git a/src/metabase/driver/sql/query_processor.clj b/src/metabase/driver/sql/query_processor.clj
index a6621bf20cc..10ce0ef31d2 100644
--- a/src/metabase/driver/sql/query_processor.clj
+++ b/src/metabase/driver/sql/query_processor.clj
@@ -138,22 +138,32 @@
       (truncate-fn expr))))
 
 (s/defn adjust-day-of-week
-  "Adjust day of week wrt start of week setting."
+  "Adjust day of week to respect the [[metabase.public-settings/start-of-week]] Setting.
+
+  The value a `:day-of-week` extract should return depends on the value of `start-of-week`, by default Sunday.
+
+  * `1` = first day of the week (e.g. Sunday)
+  * `7` = last day of the week (e.g. Saturday)
+
+  This assumes `day-of-week` as returned by the driver is already between `1` and `7` (adjust it if it's not). It
+  adjusts as needed to match `start-of-week` by the [[driver.common/start-of-week-offset]], which comes
+  from [[driver/db-start-of-week]]."
   ([driver day-of-week]
    (adjust-day-of-week driver day-of-week (driver.common/start-of-week-offset driver)))
 
   ([driver day-of-week offset]
    (adjust-day-of-week driver day-of-week offset hx/mod))
 
-  ([_driver
+  ([driver
     day-of-week
     offset :- s/Int
     mod-fn :- (s/pred fn?)]
-   (if (not= offset 0)
-     (hsql/call :case
-       (hsql/call := (mod-fn (hx/+ day-of-week offset) 7) 0) 7
-       :else                                                 (mod-fn (hx/+ day-of-week offset) 7))
-     day-of-week)))
+   (cond
+     (zero? offset) day-of-week
+     (neg? offset)  (recur driver day-of-week (+ offset 7) mod-fn)
+     :else          (hsql/call :case
+                      (hsql/call := (mod-fn (hx/+ day-of-week offset) 7) 0) 7
+                      :else                                                 (mod-fn (hx/+ day-of-week offset) 7)))))
 
 (defmulti quote-style
   "Return the quoting style that should be used by [HoneySQL](https://github.com/jkk/honeysql) when building a SQL
diff --git a/test/metabase/query_processor_test/date_bucketing_test.clj b/test/metabase/query_processor_test/date_bucketing_test.clj
index 9c0cbf5283e..41d40987ecf 100644
--- a/test/metabase/query_processor_test/date_bucketing_test.clj
+++ b/test/metabase/query_processor_test/date_bucketing_test.clj
@@ -1163,6 +1163,24 @@
                         :breakout    [!day-of-week.date]
                         :filter      [:between $date "2013-01-03" "2013-01-20"]}))))))))))
 
+(deftest first-day-of-week-for-day-of-week-bucketing-test
+  (testing "First day of week for `:day-of-week` bucketing should be the consistent (#17801)"
+    (mt/test-drivers (mt/normal-drivers-with-feature :basic-aggregations)
+      (let [query (mt/mbql-query checkins
+                    {:aggregation [[:count]]
+                     :breakout    [!day-of-week.date]})]
+        (doseq [[first-day-of-week expected-rows] {:sunday    [[1 135] [2 143] [3 153] [4 136] [5 139] [6 160] [7 134]]
+                                                   :monday    [[1 143] [2 153] [3 136] [4 139] [5 160] [6 134] [7 135]]
+                                                   :tuesday   [[1 153] [2 136] [3 139] [4 160] [5 134] [6 135] [7 143]]
+                                                   :wednesday [[1 136] [2 139] [3 160] [4 134] [5 135] [6 143] [7 153]]
+                                                   :thursday  [[1 139] [2 160] [3 134] [4 135] [5 143] [6 153] [7 136]]
+                                                   :friday    [[1 160] [2 134] [3 135] [4 143] [5 153] [6 136] [7 139]]
+                                                   :saturday  [[1 134] [2 135] [3 143] [4 153] [5 136] [6 139] [7 160]]}]
+          (mt/with-temporary-setting-values [start-of-week first-day-of-week]
+            (mt/with-native-query-testing-context query
+              (is (= expected-rows
+                     (mt/formatted-rows [int int] (qp/process-query query)))))))))))
+
 (deftest filter-by-current-quarter-test
   ;; Oracle doesn't work on March 31st because March 31st + 3 months = June 31st, which doesn't exist. See #10072
   (mt/test-drivers (disj (mt/normal-drivers) :oracle)
-- 
GitLab