From 23595c12550e8041b8cc15aa1fcdf61e85915a72 Mon Sep 17 00:00:00 2001
From: adam-james <21064735+adam-james-v@users.noreply.github.com>
Date: Tue, 17 Sep 2024 12:26:30 -0700
Subject: [PATCH] Pivot Exports/Downloads Should Not include pivot-grouping or
 'Extra' Rows (#47832)

* Pivot Exports/Downloads Should Not include pivot-grouping or 'Extra' Rows

Fixes 46561

When downloading a pivot table's data, a column 'pivot-grouping' is included as well as 'extra' rows that correspond
to various totals in the table.

This PR removes the pivot-grouping column and any of these 'extra' rows from the downloads/exports.

* fix xlsx

* Fix csv

* fix json

* add test and fix some failing tests

* fmt

* Update src/metabase/query_processor/streaming/csv.clj

Co-authored-by: metamben <103100869+metamben@users.noreply.github.com>

* static viz table renders filter out pivot-grouping

* address review feedback

* add some comments to try explain the changes

---------

Co-authored-by: metamben <103100869+metamben@users.noreply.github.com>
---
 src/metabase/pulse/render/table.clj           | 28 ++++--
 .../query_processor/pivot/postprocess.clj     |  5 ++
 .../query_processor/streaming/csv.clj         | 42 ++++++---
 .../query_processor/streaming/json.clj        | 67 +++++++++-----
 .../query_processor/streaming/xlsx.clj        | 90 +++++++++++--------
 test/metabase/api/downloads_exports_test.clj  | 57 +++++++++---
 test/metabase/pulse/render/body_test.clj      | 25 ++++++
 test/metabase/pulse/render/test_util.clj      | 21 +++++
 8 files changed, 237 insertions(+), 98 deletions(-)

diff --git a/src/metabase/pulse/render/table.clj b/src/metabase/pulse/render/table.clj
index f60e7183036..103e539aed7 100644
--- a/src/metabase/pulse/render/table.clj
+++ b/src/metabase/pulse/render/table.clj
@@ -151,12 +151,22 @@
    (render-table color-selector 0 column-names-map contents))
 
   ([color-selector normalized-zero {:keys [col-names cols-for-color-lookup]} [header & rows]]
-   [:table {:style       (style/style {:max-width     "100%"
-                                       :white-space   :nowrap
-                                       :border        (str "1px solid " style/color-border)
-                                       :border-radius :6px
-                                       :width         "1%"})
-            :cellpadding "0"
-            :cellspacing "0"}
-    (render-table-head (vec col-names) header)
-    (render-table-body (partial color/get-background-color color-selector) normalized-zero cols-for-color-lookup rows)]))
+   (let [pivot-grouping-idx (get (zipmap col-names (range)) "pivot-grouping")
+         col-names          (cond->> col-names
+                              pivot-grouping-idx (m/remove-nth pivot-grouping-idx))
+         header             (cond-> header
+                              pivot-grouping-idx (update :row #(m/remove-nth pivot-grouping-idx %)))
+         rows               (cond->> rows
+                              pivot-grouping-idx (keep (fn [row]
+                                                         (let [group (:num-value (nth (:row row) pivot-grouping-idx))]
+                                                           (when (= 0 group)
+                                                             (update row :row #(m/remove-nth pivot-grouping-idx %)))))))]
+     [:table {:style       (style/style {:max-width     "100%"
+                                         :white-space   :nowrap
+                                         :border        (str "1px solid " style/color-border)
+                                         :border-radius :6px
+                                         :width         "1%"})
+              :cellpadding "0"
+              :cellspacing "0"}
+      (render-table-head (vec col-names) header)
+      (render-table-body (partial color/get-background-color color-selector) normalized-zero cols-for-color-lookup rows)])))
diff --git a/src/metabase/query_processor/pivot/postprocess.clj b/src/metabase/query_processor/pivot/postprocess.clj
index a3d4ff1cc2d..7f2225c9838 100644
--- a/src/metabase/query_processor/pivot/postprocess.clj
+++ b/src/metabase/query_processor/pivot/postprocess.clj
@@ -24,6 +24,11 @@
 
 ;; The 'pivot-grouping' is the giveaway. If you ever see that column, you know you're dealing with raw pivot rows.
 
+(def NON_PIVOT_ROW_GROUP
+  "Pivot query results have a 'pivot-grouping' column. Rows whose pivot-grouping value is 0 are expected results.
+  Rows whose pivot-grouping values are greater than 0 represent subtotals, and should not be included in non-pivot result outputs."
+  0)
+
 ;; Most of the post processing functions use a 'pivot-spec' map.
 (mr/def ::pivot-spec
   [:map
diff --git a/src/metabase/query_processor/streaming/csv.clj b/src/metabase/query_processor/streaming/csv.clj
index 9dd0588d45d..b3ace420617 100644
--- a/src/metabase/query_processor/streaming/csv.clj
+++ b/src/metabase/query_processor/streaming/csv.clj
@@ -2,6 +2,7 @@
   (:require
    [clojure.data.csv]
    [java-time.api :as t]
+   [medley.core :as m]
    [metabase.formatter :as formatter]
    [metabase.query-processor.pivot.postprocess :as qp.pivot.postprocess]
    [metabase.query-processor.streaming.common :as common]
@@ -73,15 +74,20 @@
   (let [writer             (BufferedWriter. (OutputStreamWriter. os StandardCharsets/UTF_8))
         ordered-formatters (volatile! nil)
         rows!              (atom [])
-        pivot-options      (atom nil)]
+        pivot-options      (atom nil)
+        ;; if we're processing results from a pivot query, there will be a column 'pivot-grouping' that we don't want to include
+        ;; in the final results, so we get the idx into the row in order to remove it
+        pivot-grouping-idx (volatile! nil)]
     (reify qp.si/StreamingResultsWriter
       (begin! [_ {{:keys [ordered-cols results_timezone format-rows? pivot-export-options]
                    :or   {format-rows? true}} :data} viz-settings]
-        (let [opts      (when (and *pivot-export-post-processing-enabled* pivot-export-options)
-                          (assoc pivot-export-options :column-titles (mapv :display_name ordered-cols)))
+        (let [opts           (when (and *pivot-export-post-processing-enabled* pivot-export-options)
+                               (assoc pivot-export-options :column-titles (mapv :display_name ordered-cols)))
               ;; col-names are created later when exporting a pivot table, so only create them if there are no pivot options
-              col-names (when-not opts (common/column-titles ordered-cols (::mb.viz/column-settings viz-settings) format-rows?))]
+              col-names      (when-not opts (common/column-titles ordered-cols (::mb.viz/column-settings viz-settings) format-rows?))
+              pivot-grouping (qp.pivot.postprocess/pivot-grouping-key col-names)]
           ;; when pivot options exist, we want to save them to access later when processing the complete set of results for export.
+          (when pivot-grouping (vreset! pivot-grouping-idx pivot-grouping))
           (when opts
             (reset! pivot-options (merge {:pivot-rows []
                                           :pivot-cols []} opts)))
@@ -90,15 +96,18 @@
                      (mapv #(formatter/create-formatter results_timezone % viz-settings) ordered-cols)
                      (vec (repeat (count ordered-cols) identity))))
           ;; write the column names for non-pivot tables
-          (when col-names
-            (write-csv writer [col-names])
+          (when-not opts
+            (let [modified-row (cond->> col-names
+                                 @pivot-grouping-idx (m/remove-nth @pivot-grouping-idx))]
+              (write-csv writer [modified-row]))
             (.flush writer))))
 
       (write-row! [_ row _row-num _ {:keys [output-order]}]
-        (let [ordered-row (if output-order
-                            (let [row-v (into [] row)]
-                              (for [i output-order] (row-v i)))
-                            row)
+        (let [ordered-row (vec
+                           (if output-order
+                             (let [row-v (into [] row)]
+                               (for [i output-order] (row-v i)))
+                             row))
               xf-row      (perf/mapv (fn [formatter r]
                                        (formatter (common/format-value r)))
                                      @ordered-formatters ordered-row)]
@@ -106,9 +115,16 @@
             ;; if we're processing a pivot result, we don't write it out yet, just store it
             ;; so that we can post process the full set of results in finish!
             (swap! rows! conj xf-row)
-            (do
-              (write-csv writer [xf-row])
-              (.flush writer)))))
+            (let [pivot-grouping-key @pivot-grouping-idx
+                  group              (get ordered-row pivot-grouping-key)
+                  cleaned-row        (cond->> xf-row
+                                       pivot-grouping-key (m/remove-nth pivot-grouping-key))]
+              ;; when a pivot-grouping col exists, we check its group number. When it's zero,
+              ;; we keep it, otherwise don't include it in the results as it's a row representing a subtotal of some kind
+              (when (or (= qp.pivot.postprocess/NON_PIVOT_ROW_GROUP group)
+                        (not group))
+                (write-csv writer [cleaned-row])
+                (.flush writer))))))
 
       (finish! [_ _]
         ;; TODO -- not sure we need to flush both
diff --git a/src/metabase/query_processor/streaming/json.clj b/src/metabase/query_processor/streaming/json.clj
index 5bcd20a0f95..3a73d3dcac0 100644
--- a/src/metabase/query_processor/streaming/json.clj
+++ b/src/metabase/query_processor/streaming/json.clj
@@ -6,7 +6,9 @@
    [cheshire.factory :as json.factory]
    [cheshire.generate :as json.generate]
    [java-time.api :as t]
+   [medley.core :as m]
    [metabase.formatter :as formatter]
+   [metabase.query-processor.pivot.postprocess :as qp.pivot.postprocess]
    [metabase.query-processor.streaming.common :as common]
    [metabase.query-processor.streaming.interface :as qp.si]
    [metabase.shared.models.visualization-settings :as mb.viz]
@@ -32,41 +34,58 @@
   [_ ^OutputStream os]
   (let [writer             (BufferedWriter. (OutputStreamWriter. os StandardCharsets/UTF_8))
         col-names          (volatile! nil)
-        ordered-formatters (volatile! nil)]
+        ordered-formatters (volatile! nil)
+        ;; if we're processing results from a pivot query, there will be a column 'pivot-grouping' that we don't want to include
+        ;; in the final results, so we get the idx into the row in order to remove it
+        pivot-grouping-idx (volatile! nil)]
     (reify qp.si/StreamingResultsWriter
       (begin! [_ {{:keys [ordered-cols results_timezone format-rows?]
                    :or   {format-rows? true}} :data} viz-settings]
         ;; TODO -- wouldn't it make more sense if the JSON downloads used `:name` preferentially? Seeing how JSON is
         ;; probably going to be parsed programmatically
-        (vreset! col-names (common/column-titles ordered-cols (::mb.viz/column-settings viz-settings) format-rows?))
-        (vreset! ordered-formatters
-                 (if format-rows?
-                   (mapv #(formatter/create-formatter results_timezone % viz-settings) ordered-cols)
-                   (vec (repeat (count ordered-cols) identity))))
-        (.write writer "[\n"))
+        (let [cols           (common/column-titles ordered-cols (::mb.viz/column-settings viz-settings) format-rows?)
+              pivot-grouping (qp.pivot.postprocess/pivot-grouping-key cols)]
+          (when pivot-grouping (vreset! pivot-grouping-idx pivot-grouping))
+          (let [names (cond->> cols
+                        pivot-grouping (m/remove-nth pivot-grouping))]
+            (vreset! col-names names))
+          (vreset! ordered-formatters
+                   (if format-rows?
+                     (mapv #(formatter/create-formatter results_timezone % viz-settings) ordered-cols)
+                     (vec (repeat (count ordered-cols) identity))))
+          (.write writer "[\n")))
 
       (write-row! [_ row row-num _ {:keys [output-order]}]
-        (let [ordered-row (if output-order
-                            (let [row-v (into [] row)]
-                              (for [i output-order] (row-v i)))
-                            row)]
-          (when-not (zero? row-num)
-            (.write writer ",\n"))
-          (json/generate-stream
-           (zipmap
-            @col-names
-            (map (fn [formatter r]
+        (let [ordered-row        (vec
+                                  (if output-order
+                                    (let [row-v (into [] row)]
+                                      (for [i output-order] (row-v i)))
+                                    row))
+              pivot-grouping-key @pivot-grouping-idx
+              group              (get ordered-row pivot-grouping-key)
+              cleaned-row        (cond->> ordered-row
+                                   pivot-grouping-key (m/remove-nth pivot-grouping-key))]
+          ;; when a pivot-grouping col exists, we check its group number. When it's zero,
+          ;; we keep it, otherwise don't include it in the results as it's a row representing a subtotal of some kind
+          (when (or (= qp.pivot.postprocess/NON_PIVOT_ROW_GROUP group)
+                    (not group))
+            (when-not (zero? row-num)
+              (.write writer ",\n"))
+            (json/generate-stream
+             (zipmap
+              @col-names
+              (map (fn [formatter r]
                      ;; NOTE: Stringification of formatted values ensures consistency with what is shown in the
                      ;; Metabase UI, especially numbers (e.g. percents, currencies, and rounding). However, this
                      ;; does mean that all JSON values are strings. Any other strategy requires some level of
                      ;; inference to know if we should or should not parse a string (or not stringify an object).
-                   (let [res (formatter (common/format-value r))]
-                     (if-some [num-str (:num-str res)]
-                       num-str
-                       res)))
-                 @ordered-formatters ordered-row))
-           writer)
-          (.flush writer)))
+                     (let [res (formatter (common/format-value r))]
+                       (if-some [num-str (:num-str res)]
+                         num-str
+                         res)))
+                   @ordered-formatters cleaned-row))
+             writer)
+            (.flush writer))))
 
       (finish! [_ _]
         (.write writer "\n]")
diff --git a/src/metabase/query_processor/streaming/xlsx.clj b/src/metabase/query_processor/streaming/xlsx.clj
index 67a9d9bf796..f4bf719b069 100644
--- a/src/metabase/query_processor/streaming/xlsx.clj
+++ b/src/metabase/query_processor/streaming/xlsx.clj
@@ -616,22 +616,26 @@
 
 (defmethod qp.si/streaming-results-writer :xlsx
   [_ ^OutputStream os]
-  (let [workbook          (SXSSFWorkbook.)
-        sheet             (spreadsheet/add-sheet! workbook (tru "Query result"))
-        _                 (set-no-style-custom-helper sheet)
-        data-format       (. workbook createDataFormat)
-        cell-styles       (volatile! nil)
-        typed-cell-styles (volatile! nil)
-        pivot-data!       (atom {:rows []})]
+  (let [workbook           (SXSSFWorkbook.)
+        sheet              (spreadsheet/add-sheet! workbook (tru "Query result"))
+        _                  (set-no-style-custom-helper sheet)
+        data-format        (. workbook createDataFormat)
+        cell-styles        (volatile! nil)
+        typed-cell-styles  (volatile! nil)
+        pivot-data!        (atom {:rows []})
+        ;; if we're processing results from a pivot query, there will be a column 'pivot-grouping' that we don't want to include
+        ;; in the final results, so we get the idx into the row in order to remove it
+        pivot-grouping-idx (volatile! nil)]
     (reify qp.si/StreamingResultsWriter
       (begin! [_ {{:keys [ordered-cols format-rows? pivot-export-options]} :data}
                {col-settings ::mb.viz/column-settings :as viz-settings}]
-        (let [opts      (when (and *pivot-export-post-processing-enabled* pivot-export-options)
-                          (pivot-opts->pivot-spec (merge {:pivot-cols []
-                                                          :pivot-rows []}
-                                                         pivot-export-options) ordered-cols))
-              ;; col-names are created later when exporting a pivot table, so only create them if there are no pivot options
-              col-names (when-not opts (common/column-titles ordered-cols (::mb.viz/column-settings viz-settings) format-rows?))]
+        (let [opts           (when (and *pivot-export-post-processing-enabled* pivot-export-options)
+                               (pivot-opts->pivot-spec (merge {:pivot-cols []
+                                                               :pivot-rows []}
+                                                              pivot-export-options) ordered-cols))
+              col-names      (common/column-titles ordered-cols (::mb.viz/column-settings viz-settings) format-rows?)
+              pivot-grouping (qp.pivot.postprocess/pivot-grouping-key col-names)]
+          (when pivot-grouping (vreset! pivot-grouping-idx pivot-grouping))
           (vreset! cell-styles (compute-column-cell-styles workbook data-format viz-settings ordered-cols))
           (vreset! typed-cell-styles (compute-typed-cell-styles workbook data-format))
           ;; when pivot options exist, we want to save them to access later when processing the complete set of results for export.
@@ -642,41 +646,49 @@
                                      :viz-settings viz-settings}
                    :pivot-options opts))
 
-          (when col-names
+          (when-not opts
             (doseq [i (range (count ordered-cols))]
               (.trackColumnForAutoSizing ^SXSSFSheet sheet i))
             (setup-header-row! sheet (count ordered-cols))
-            (spreadsheet/add-row! sheet (common/column-titles ordered-cols col-settings true)))))
+            (let [modified-row (cond->> (common/column-titles ordered-cols (::mb.viz/column-settings viz-settings) true)
+                                 @pivot-grouping-idx (m/remove-nth @pivot-grouping-idx))]
+              (spreadsheet/add-row! sheet modified-row)))))
 
       (write-row! [_ row row-num ordered-cols {:keys [output-order] :as viz-settings}]
-        (let [ordered-row             (if output-order
-                                        (let [row-v (into [] row)]
-                                          (for [i output-order] (row-v i)))
-                                        row)
+        (let [ordered-row             (vec (if output-order
+                                             (let [row-v (into [] row)]
+                                               (for [i output-order] (row-v i)))
+                                             row))
               col-settings            (::mb.viz/column-settings viz-settings)
-              {:keys [pivot-options]} @pivot-data!]
-          (if pivot-options
-            (let [{:keys [pivot-grouping-key]} pivot-options
-                  group                        (get row pivot-grouping-key)]
-              (when (= 0 group)
-                ;; TODO: right now, the way I'm building up the native pivot,
-                ;; I end up using the docjure set-cell! (since I create a whole sheet with all the rows at once)
-                ;; I'll want to change that so I can use the set-cell! method we have in this ns, but for now just string everything.
-                (let [modified-row (->> (vec (m/remove-nth pivot-grouping-key row))
-                                        (mapv (fn [value]
-                                                (if (number? value)
-                                                  value
-                                                  (str value)))))]
-                  (swap! pivot-data! update :rows conj modified-row))))
-            (do
-              (add-row! sheet ordered-row ordered-cols col-settings @cell-styles @typed-cell-styles)
-              (when (= (inc row-num) *auto-sizing-threshold*)
-                (autosize-columns! sheet))))))
+              {:keys [pivot-options]} @pivot-data!
+              pivot-grouping-key      @pivot-grouping-idx
+              group                   (get ordered-row pivot-grouping-key)
+              cleaned-row             (cond->> ordered-row
+                                        pivot-grouping-key (m/remove-nth pivot-grouping-key))]
+          ;; when a pivot-grouping col exists, we check its group number. When it's zero,
+          ;; we keep it, otherwise don't include it in the results as it's a row representing a subtotal of some kind
+          (when (or (= qp.pivot.postprocess/NON_PIVOT_ROW_GROUP group)
+                    (not group))
+            (if pivot-options
+              ;; TODO: right now, the way I'm building up the native pivot,
+              ;; I end up using the docjure set-cell! (since I create a whole sheet with all the rows at once)
+              ;; I'll want to change that so I can use the set-cell! method we have in this ns, but for now just string everything.
+              (let [modified-row (mapv (fn [value]
+                                         (if (number? value)
+                                           value
+                                           (str value)))
+                                       cleaned-row)]
+                (swap! pivot-data! update :rows conj modified-row))
+              (do
+                (add-row! sheet cleaned-row ordered-cols col-settings @cell-styles @typed-cell-styles)
+                (when (= (inc row-num) *auto-sizing-threshold*)
+                  (autosize-columns! sheet)))))))
 
       (finish! [_ {:keys [row_count]}]
-        (let [{:keys [pivot-options rows cell-style-data]} @pivot-data!]
+        (let [{:keys [pivot-options rows cell-style-data]} @pivot-data!
+              pivot-grouping-key                           @pivot-grouping-idx]
           (if pivot-options
-            (let [header (vec (m/remove-nth (:pivot-grouping-key pivot-options) (:column-titles pivot-options)))
+            (let [header (vec (m/remove-nth pivot-grouping-key (:column-titles pivot-options)))
                   wb     (native-pivot (concat [header] rows) pivot-options cell-style-data)]
               (try
                 (spreadsheet/save-workbook-into-stream! os wb)
diff --git a/test/metabase/api/downloads_exports_test.clj b/test/metabase/api/downloads_exports_test.clj
index 6529d9e3eb1..39147e35faa 100644
--- a/test/metabase/api/downloads_exports_test.clj
+++ b/test/metabase/api/downloads_exports_test.clj
@@ -42,12 +42,20 @@
                  (->>  (spreadsheet/cell-seq r)
                        (mapv read-cell-with-formatting)))))))
 
+(defn- tabulate-maps
+  [result]
+  (let [ks (keys (first result))]
+    (cons
+     (mapv name ks)
+     (map #(mapv % ks) result))))
+
 (defn- process-results
   [export-format results]
   (when (seq results)
     (case export-format
       :csv  (csv/read-csv results)
-      :xlsx (read-xlsx results))))
+      :xlsx (read-xlsx results)
+      :json (tabulate-maps results))))
 
 (defn- card-download
   [{:keys [id] :as _card} export-format format-rows?]
@@ -533,12 +541,12 @@
                                                                  [:field (mt/id :products :created_at) {:base-type :type/DateTime :temporal-unit :month}]]}}}]
           (let [result (->> (mt/user-http-request :crowberto :post 200 (format "card/%d/query/csv?format_rows=false" pivot-card-id))
                             csv/read-csv)]
-            (is (= [["Category" "Created At" "pivot-grouping" "Sum of Price"]
-                    ["Doohickey" "2016-05-01T00:00:00Z" "0" "144.12"]
-                    ["Doohickey" "2016-06-01T00:00:00Z" "0" "82.92"]
-                    ["Doohickey" "2016-07-01T00:00:00Z" "0" "78.22"]
-                    ["Doohickey" "2016-08-01T00:00:00Z" "0" "71.09"]
-                    ["Doohickey" "2016-09-01T00:00:00Z" "0" "45.65"]]
+            (is (= [["Category" "Created At" "Sum of Price"]
+                    ["Doohickey" "2016-05-01T00:00:00Z" "144.12"]
+                    ["Doohickey" "2016-06-01T00:00:00Z" "82.92"]
+                    ["Doohickey" "2016-07-01T00:00:00Z" "78.22"]
+                    ["Doohickey" "2016-08-01T00:00:00Z" "71.09"]
+                    ["Doohickey" "2016-09-01T00:00:00Z" "45.65"]]
                    (take 6 result)))))))
     (testing "for xlsx"
       (mt/dataset test-data
@@ -562,12 +570,11 @@
                                          (mapv (fn [row] (->> (spreadsheet/cell-seq row)
                                                               (mapv spreadsheet/read-cell)))))]
                            data))]
-            (is (= [["Category" "pivot-grouping" "Sum of Price"]
-                    ["Doohickey" 0.0 2185.89]
-                    ["Gadget" 0.0 3019.2]
-                    ["Gizmo" 0.0 2834.88]
-                    ["Widget" 0.0 3109.31]
-                    [nil 1.0 11149.28]]
+            (is (= [["Category" "Sum of Price"]
+                    ["Doohickey" 2185.89]
+                    ["Gadget" 3019.2]
+                    ["Gizmo" 2834.88]
+                    ["Widget" 3109.31]]
                    (take 6 data)))))))))
 
 (deftest ^:parallel dashcard-viz-settings-downloads-test
@@ -783,3 +790,27 @@
                   (is (true? (str/includes? results-string "Test Exception")))
                   (testing (format "String \"%s\" is not in the error message." illegal)
                     (is (false? (str/includes? results-string illegal)))))))))))))
+
+(deftest unpivoted-pivot-results-do-not-include-pivot-grouping
+  (testing "If a pivot question is downloaded or exported unpivoted, the results do not include 'pivot-grouping' column"
+    (doseq [export-format ["csv" "xlsx" "json"]]
+      (testing (format "for %s" export-format)
+        (mt/dataset test-data
+          (mt/with-temp [:model/Card {pivot-card-id :id}
+                         {:display                :pivot
+                          :visualization_settings {:pivot_table.column_split
+                                                   {:rows    []
+                                                    :columns [[:field (mt/id :products :category) {:base-type :type/Text}]]
+                                                    :values  [[:aggregation 0]]}}
+                          :dataset_query          {:database (mt/id)
+                                                   :type     :query
+                                                   :query
+                                                   {:source-table (mt/id :products)
+                                                    :aggregation  [[:sum [:field (mt/id :products :price) {:base-type :type/Float}]]]
+                                                    :breakout     [[:field (mt/id :products :category) {:base-type :type/Text}]]}}}]
+            (let [result (mt/user-http-request :crowberto :post 200
+                                               (format "card/%d/query/%s?format_rows=false" pivot-card-id export-format)
+                                               {})
+                  data   (process-results (keyword export-format) result)]
+              (is (= ["Category" "Sum of Price"]
+                     (first data))))))))))
diff --git a/test/metabase/pulse/render/body_test.clj b/test/metabase/pulse/render/body_test.clj
index 510726daf0d..b928e86d410 100644
--- a/test/metabase/pulse/render/body_test.clj
+++ b/test/metabase/pulse/render/body_test.clj
@@ -974,3 +974,28 @@
                        (map vector
                             (range)
                             (map :content (take 20 card-row-els)))))))))))))
+
+(deftest table-renders-excludes-pivot-grouping
+  (testing "Rendered Tables respect the provided viz-settings on the dashcard."
+    (mt/dataset test-data
+      (mt/with-temp [:model/Card {card-id :id}
+                     {:display                :pivot
+                      :visualization_settings {:pivot_table.column_split
+                                               {:rows    [[:field (mt/id :products :category) {:base-type :type/Text}]]
+                                                :columns [[:field (mt/id :products :created_at) {:base-type :type/DateTime :temporal-unit :year}]]
+                                                :values  [[:aggregation 0]]}
+                                               :column_settings
+                                               {"[\"name\",\"sum\"]" {:number_style       "currency"
+                                                                      :currency_in_header false}}}
+                      :dataset_query          {:database (mt/id)
+                                               :type     :query
+                                               :query
+                                               {:source-table (mt/id :products)
+                                                :aggregation  [[:sum [:field (mt/id :products :price) {:base-type :type/Float}]]]
+                                                :breakout     [[:field (mt/id :products :category) {:base-type :type/Text}]
+                                                               [:field (mt/id :products :created_at) {:base-type :type/DateTime :temporal-unit :year}]]}}}]
+        (mt/with-current-user (mt/user->id :rasta)
+          (let [card-doc        (render.tu/render-pivot-card-as-hickory! card-id)
+                card-header-els (hik.s/select (hik.s/tag :th) card-doc)]
+            (is (=  ["Category" "Created At" "Sum of Price"]
+                    (mapv (comp first :content) card-header-els)))))))))
diff --git a/test/metabase/pulse/render/test_util.clj b/test/metabase/pulse/render/test_util.clj
index cce0701b702..85b8d09a88e 100644
--- a/test/metabase/pulse/render/test_util.clj
+++ b/test/metabase/pulse/render/test_util.clj
@@ -15,6 +15,7 @@
    [metabase.pulse.util :as pu]
    [metabase.query-processor :as qp]
    [metabase.query-processor.card :as qp.card]
+   [metabase.query-processor.pivot :as qp.pivot]
    [toucan2.core :as t2])
   (:import
    (org.apache.batik.anim.dom SVGOMDocument AbstractElement$ExtendedNamedNodeHashMap)
@@ -152,6 +153,26 @@
             hik/parse
             hik/as-hickory)))))
 
+(defn render-pivot-card-as-hickory!
+  "Render the card with `card-id` using the pivot qp and the static-viz rendering pipeline as a hickory data structure.
+  Redefines some internal rendering functions to keep svg from being rendered into a png. Functions from `hickory.select`
+  can be used on the output of this function and are particularly useful for writing test assertions."
+  [card-id]
+  (let [{:keys [visualization_settings] :as card} (t2/select-one :model/Card :id card-id)
+        query                                     (qp.card/query-for-card card [] nil {:process-viz-settings? true} nil)
+        results                                   (qp.pivot/run-pivot-query (assoc query :viz-settings visualization_settings))]
+    (with-redefs [js-svg/svg-string->bytes       identity
+                  image-bundle/make-image-bundle (fn [_ s]
+                                                   {:image-src   s
+                                                    :render-type :inline})]
+      (let [content (-> (render/render-pulse-card :inline "UTC" card nil results)
+                        :content)]
+        (-> content
+            (edit-nodes img-node-with-svg? img-node->svg-node) ;; replace the :img tag with its parsed SVG.
+            hiccup/html
+            hik/parse
+            hik/as-hickory)))))
+
 (defn render-dashcard-as-hickory!
   "Render the dashcard with `dashcard-id` using the static-viz rendering pipeline as a hickory data structure. Redefines
   some internal rendering functions to keep svg from being rendered into a png. Functions from `hickory.select` can be
-- 
GitLab