Repository: metabase/metabase
Pull request #57728: a transformation layer for event info before evaluating the condition
Author: login: qnkhuat
Created: 2025-05-08T08:57:28Z | Merged: 2025-05-08T12:35:39Z

Add a transformation step to translate the event info published to a format that'll be used for evaluate the condition.

The schema of the event info that'll be used for evaluate the condition will looks like

```json
{"table_id": 1,
 "record": {"ID": 1, "NAME": "Ngoc"},
  "event_name": "event/row.created"}
```


Diff:
diff --git a/enterprise/backend/test/metabase_enterprise/data_editing/notification_test.clj b/enterprise/backend/test/metabase_enterprise/data_editing/notification_test.clj
index cd8a3225138d..fa9432ed5999 100644
--- a/enterprise/backend/test/metabase_enterprise/data_editing/notification_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/data_editing/notification_test.clj
@@ -6,6 +6,7 @@
    [metabase.notification.events.notification :as events.notification]
    [metabase.notification.test-util :as notification.tu]
    [metabase.test :as mt]
+   [metabase.util :as u]
    [toucan2.core :as t2]))
 
 (use-fixtures :each (fn [thunk]
@@ -27,16 +28,20 @@
   [prop request-fn channel-type->assert-fns]
   (data-editing.tu/with-temp-test-db!
     (mt/with-temp [:model/Channel chn {:type :channel/http}]
-      (notification.tu/with-system-event-notification!
-        [notification {:notification-system-event {:event_name (:event_name prop)
-                                                   :table_id   (mt/id (or (:table prop) :categories))}
-                       :handlers                  (all-handlers (:id chn))}]
-        (notification.tu/with-channel-fixtures [:channel/email :channel/slack]
-          (let [channel-type->captured-message (notification.tu/with-captured-channel-send!
-                                                 (request-fn notification))]
-            (doseq [[channel-type assert-fn] channel-type->assert-fns]
-              (testing (format "channel-type = %s" channel-type)
-                (assert-fn (get channel-type->captured-message channel-type))))))))))
+      (let [table-id (mt/id (or (:table prop) :categories))]
+        (notification.tu/with-system-event-notification!
+          [notification {:notification-system-event {:event_name (:event_name prop)
+                                                     :table_id   table-id}
+                         :notification           {:condition  [:and
+                                                               [:= [:context :table_id] table-id]
+                                                               [:= [:context :event_name] (u/qualified-name (:event_name prop))]]}
+                         :handlers                  (all-handlers (:id chn))}]
+          (notification.tu/with-channel-fixtures [:channel/email :channel/slack]
+            (let [channel-type->captured-message (notification.tu/with-captured-channel-send!
+                                                   (request-fn notification))]
+              (doseq [[channel-type assert-fn] channel-type->assert-fns]
+                (testing (format "channel-type = %s" channel-type)
+                  (assert-fn (get channel-type->captured-message channel-type)))))))))))
 
 (deftest create-row-notification-test
   (test-row-notification!
diff --git a/src/metabase/notification/condition.clj b/src/metabase/notification/condition.clj
index 6c1b74ad8f8c..e6e85ddf5299 100644
--- a/src/metabase/notification/condition.clj
+++ b/src/metabase/notification/condition.clj
@@ -1,9 +1,12 @@
-(ns metabase.notification.condition)
+(ns metabase.notification.condition
+  (:require
+   [metabase.util :as u]))
 
 (defn evaluate-expression
   "Evaluates an array-based expression against a context payload"
   [expr context]
-  (if (sequential? expr)
+  (cond
+    (sequential? expr)
     (let [operator (first expr)
           operands (rest expr)]
       (case (keyword operator)
@@ -21,18 +24,26 @@
         :<= (apply <= (map #(evaluate-expression % context) operands))
 
         ;; Data access
-        :context (get-in context (map keyword operands))
+        :context (let [v (get-in context (map keyword operands))]
+                   (if (keyword? v)
+                     (u/qualified-name v)
+                     v))
 
         ;; Functions
         :count (count (evaluate-expression (first operands) context))
         :min   (apply min (map #(evaluate-expression % context) operands))
         :max   (apply max (map #(evaluate-expression % context) operands))))
+    ;; keyword are converted to string
+    (keyword? expr)
     ;; literal value
-    expr))
+    (u/qualified-name expr)
+    :else expr))
 
 (comment
   (evaluate-expression ["and",
                         [">", ["count", ["context", "rows"]], 0],
-                        ["=", ["context", "user_id"], 1]]
+                        ["=", ["context", "user_id"], 1]
+                        ["=", ["context", "event_name"], "created"]]
                        {:user_id 1
-                        :rows [1 2 3 4]}))
+                        :rows [1 2 3 4]
+                        :event_name :created}))
diff --git a/src/metabase/notification/payload/impl/system_event.clj b/src/metabase/notification/payload/impl/system_event.clj
index fbe1f1d04e85..e821c9da40d5 100644
--- a/src/metabase/notification/payload/impl/system_event.clj
+++ b/src/metabase/notification/payload/impl/system_event.clj
@@ -31,18 +31,21 @@
       ;; NOTE: the new user join url is just a password reset with an indicator that this is a first time user
       (str (user/form-password-reset-url reset-token) "#new"))))
 
-(defn- dispatch-on-event-info
-  [{notification-system-event :payload :as _notification-info}]
-  (let [{:keys [action event_name]} notification-system-event]
-    (if action
-      [event_name action]
-      event_name)))
-
 (defmulti transform-event-info
   "Transform the event info to a format that is easier to work with in the templates.
   This is a multi-method because we want to be able to add more event types in the future."
   {:arglists '([notification-info])}
-  dispatch-on-event-info)
+  (comp :event_name :payload))
+
+(defmulti event-info->condition-context
+  "Transformed the published event info to an intermediate datastructure that's used for evaluating the condition"
+  {:arglists '([event-topic event-info])}
+  (fn [event-topic _event-info]
+    event-topic))
+
+(defmethod event-info->condition-context :default
+  [_event-topic event-info]
+  event-info)
 
 (defn- sample-for-table
   [table-id changes?]
@@ -297,22 +300,54 @@
 
 (mu/defmethod notification.payload/notification-payload :notification/system-event :- :map
   [notification-info :- ::notification.payload/Notification]
+  (def notification-info notification-info)
   (transform-event-info notification-info))
 
 (defmethod notification.payload/notification-payload-schema :notification/system-event
   [notification-info]
   (when-let [[op _arg-schema return-schema] (some->> notification-info
-                                                     dispatch-on-event-info
+                                                     :payload
+                                                     :event_name
                                                      (get-method transform-event-info)
                                                      meta
                                                      :schema)]
     (assert (= op :=>))
     (mr/resolve-schema return-schema)))
 
+(defn- default-row-action-transformation
+  [event-topic event-info]
+  (lib.util.match/match-one
+    event-info
+    {:row_change {:before ?before
+                  :after  ?after}
+     :args       {:table_id ?table-id}}
+    {:record     (or ?after ?before)
+     :table_id   ?table-id
+     :event_name event-topic}))
+
+(mr/def ::condition.row.mutated
+  [:map {:closed true}
+   [:record :map]
+   [:table_id pos-int?]
+   [:event_name :keyword]])
+
+(mu/defmethod event-info->condition-context :event/row.created :- ::condition.row.mutated
+  [event-topic event-info]
+  (default-row-action-transformation event-topic event-info))
+
+(mu/defmethod event-info->condition-context :event/row.updated :- ::condition.row.mutated
+  [event-topic event-info]
+  (default-row-action-transformation event-topic event-info))
+
+(mu/defmethod event-info->condition-context :event/row.deleted :- ::condition.row.mutated
+  [event-topic event-info]
+  (default-row-action-transformation event-topic event-info))
+
 (defmethod notification.send/should-queue-notification? :notification/system-event
   [notification-info]
   (if-let [condition (not-empty (:condition notification-info))]
-    (notification.condition/evaluate-expression condition (:event_info notification-info))
+    (->> (event-info->condition-context (get-in notification-info [:payload :event_name]) (:event_info notification-info))
+         (notification.condition/evaluate-expression condition))
     true))
 
 (defn sample-payload
diff --git a/test/metabase/notification/events/notification_test.clj b/test/metabase/notification/events/notification_test.clj
index 44985d8ac341..b40018685d49 100644
--- a/test/metabase/notification/events/notification_test.clj
+++ b/test/metabase/notification/events/notification_test.clj
@@ -5,42 +5,44 @@
    [metabase.notification.core :as notification]
    [metabase.notification.events.notification :as events.notification]
    [metabase.notification.models :as models.notification]
+   [metabase.notification.send :as notification.send]
    [metabase.notification.test-util :as notification.tu]
    [metabase.test :as mt]
    [toucan2.core :as t2]))
 
 (deftest supported-events-with-notification-will-be-sent-test
-  (mt/with-model-cleanup [:model/Notification]
-    (notification.tu/with-temporary-event-topics! #{:event/test-notification}
-      (let [topic      :event/test-notification
-            n-1        (models.notification/create-notification!
-                        {:payload_type :notification/system-event
-                         :active       true
-                         :payload      {:event_name topic}}
-                        nil
-                        [])
-            n-2         (models.notification/create-notification!
-                         {:payload_type :notification/system-event
-                          :active       true
-                          :payload      {:event_name topic}}
-                         nil
-                         [])
-            _inactive  (models.notification/create-notification!
-                        {:payload_type :notification/system-event
-                         :active       false
-                         :payload      {:event_name topic}}
-                        []
-                        nil)
-            sent-notis (atom [])]
-        (testing "publishing event will send all the actively subscribed notifciations"
-          (with-redefs [notification/send-notification!      (fn [notification] (swap! sent-notis conj notification))
-                        events.notification/supported-topics #{:event/test-notification}]
-            (events/publish-event! topic {::hi true})
-            (is (=? [[(:id n-1) {::hi true}]
-                     [(:id n-2) {::hi true}]]
-                    (->> @sent-notis
-                         (map (juxt :id :event_info))
-                         (sort-by first))))))))))
+  (notification.tu/with-send-notification-sync
+    (mt/with-model-cleanup [:model/Notification]
+      (notification.tu/with-temporary-event-topics! #{:event/test-notification}
+        (let [topic      :event/test-notification
+              n-1        (models.notification/create-notification!
+                          {:payload_type :notification/system-event
+                           :active       true
+                           :payload      {:event_name topic}}
+                          nil
+                          [])
+              n-2         (models.notification/create-notification!
+                           {:payload_type :notification/system-event
+                            :active       true
+                            :payload      {:event_name topic}}
+                           nil
+                           [])
+              _inactive  (models.notification/create-notification!
+                          {:payload_type :notification/system-event
+                           :active       false
+                           :payload      {:event_name topic}}
+                          []
+                          nil)
+              sent-notis (atom [])]
+          (testing "publishing event will send all the actively subscribed notifciations"
+            (with-redefs [notification.send/send-notification!      (fn [notification] (swap! sent-notis conj notification))
+                          events.notification/supported-topics #{:event/test-notification}]
+              (events/publish-event! topic {::hi true})
+              (is (=? [[(:id n-1) {::hi true}]
+                       [(:id n-2) {::hi true}]]
+                      (->> @sent-notis
+                           (map (juxt :id :event_info))
+                           (sort-by first)))))))))))
 
 (deftest unsupported-events-will-not-send-notification-test
   (mt/with-model-cleanup [:model/Notification]


Reviews:
user: login: crisptrutski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T11:08:26Z
user: login: crisptrutski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T11:09:14Z
user: login: crisptrutski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T11:09:36Z
user: login: crisptrutski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T11:10:27Z
user: login: crisptrutski
body: Some belated feedback 😄 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T11:10:51Z

Review comments:
diff_hunk: @@ -21,18 +24,26 @@
         :<= (apply <= (map #(evaluate-expression % context) operands))
 
         ;; Data access
-        :context (get-in context (map keyword operands))
+        :context (let [v (get-in context (map keyword operands))]
+                   (if (keyword? v)
+                     (u/qualified-name v)
+                     v))
path: src/metabase/notification/condition.clj
user: login: crisptrutski
body: Maybe this is too powerful, but you could also delegate this to `evaluate-expression`?
created_at: 2025-05-12T11:08:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,18 +24,26 @@
         :<= (apply <= (map #(evaluate-expression % context) operands))
 
         ;; Data access
-        :context (get-in context (map keyword operands))
+        :context (let [v (get-in context (map keyword operands))]
+                   (if (keyword? v)
+                     (u/qualified-name v)
+                     v))
path: src/metabase/notification/condition.clj
user: login: crisptrutski
body: Hmm nah that probably wouldn't make sense for most lists 😆 
created_at: 2025-05-12T11:09:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -297,22 +300,54 @@
 
 (mu/defmethod notification.payload/notification-payload :notification/system-event :- :map
   [notification-info :- ::notification.payload/Notification]
+  (def notification-info notification-info)
path: src/metabase/notification/payload/impl/system_event.clj
user: login: crisptrutski
body: Ooops (let's delete this)
created_at: 2025-05-12T11:09:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -297,22 +300,54 @@
 
 (mu/defmethod notification.payload/notification-payload :notification/system-event :- :map
   [notification-info :- ::notification.payload/Notification]
+  (def notification-info notification-info)
   (transform-event-info notification-info))
 
 (defmethod notification.payload/notification-payload-schema :notification/system-event
   [notification-info]
   (when-let [[op _arg-schema return-schema] (some->> notification-info
-                                                     dispatch-on-event-info
+                                                     :payload
+                                                     :event_name
                                                      (get-method transform-event-info)
                                                      meta
                                                      :schema)]
     (assert (= op :=>))
     (mr/resolve-schema return-schema)))
 
+(defn- default-row-action-transformation
+  [event-topic event-info]
+  (lib.util.match/match-one
+    event-info
+    {:row_change {:before ?before
+                  :after  ?after}
+     :args       {:table_id ?table-id}}
+    {:record     (or ?after ?before)
+     :table_id   ?table-id
+     :event_name event-topic}))
+
+(mr/def ::condition.row.mutated
+  [:map {:closed true}
+   [:record :map]
+   [:table_id pos-int?]
+   [:event_name :keyword]])
+
+(mu/defmethod event-info->condition-context :event/row.created :- ::condition.row.mutated
+  [event-topic event-info]
+  (default-row-action-transformation event-topic event-info))
+
+(mu/defmethod event-info->condition-context :event/row.updated :- ::condition.row.mutated
+  [event-topic event-info]
+  (default-row-action-transformation event-topic event-info))
+
+(mu/defmethod event-info->condition-context :event/row.deleted :- ::condition.row.mutated
+  [event-topic event-info]
+  (default-row-action-transformation event-topic event-info))
path: src/metabase/notification/payload/impl/system_event.clj
user: login: crisptrutski
body: Why not use `derive` on these keys so you can just define the transformation once?
created_at: 2025-05-12T11:10:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: trunk-io[bot]
created_at: 2025-05-08T09:54:36Z
body: <!-- Trunk Test Analytics -->


[![Static Badge](https://raster.shields.io/badge/5761_tests-a6cc8efd-purple)](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment) &ensp; [![Static Badge](https://raster.shields.io/badge/20-failed-crimson)](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://raster.shields.io/badge/0-flaky-red)](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://raster.shields.io/badge/0-quarantined-yellow)](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment&runConclusion=QUARANTINED)


| Failed Test | Failure Summary | Logs |
| --- | --- | --- |
| <code>scenarios > visualizations > waterfall should work with time-series data</code> | The expected element \`.Icon-waterfall\` was not found within 4000ms. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment&runConclusion=FAILURE&runId=03a7d64b-132f-54c7-883e-5496384f855e) |
| <code>scenarios > visualizations > pivot tables should be possible to switch between notebook and simple views when pivot table is the visualization (met...</code> | The test timed out while trying to find an element with data-testid "Pivot Table-button". | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment&runConclusion=FAILURE&runId=0c7d2313-1d01-51db-9f16-6602134036ca) |
| <code>scenarios > visualizations > waterfall should work with ordinal series</code> | The expected element \`.Icon-waterfall\` was not found within 4000ms. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment&runConclusion=FAILURE&runId=2474ad51-11c9-577f-9bc6-aa674d903ba6) |
| <code>datetime-math-tests</code> | BigQuery query failed with a 503 Service Unavailable error, visibility check was unavailable. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment&runConclusion=FAILURE&runId=318ca260-fea9-5eef-ac45-b6e0aed4944b) |

[... and 16 more](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment&runConclusion=FAILURE)
<sub>

[View Full Report ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/57728?repo=metabase/metabase&commitHash=a6cc8efd6eab351e80d24e27a544b79e3e84893d&utm_source=gh-comment) ⋅ [Docs](https://docs.trunk.io/flaky-tests?utm_source=gh-comment)

</sub>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T12:38:00Z
body: 
  ## e2e tests failed on `7352dd007475c9c0290e1fd6a1be91dea9f2772f-1`

  [e2e test run](https://github.com/metabase/metabase/actions/runs/14906607231?pr=57728)

  File | Test Name
  ---- | ---------
  `pivot_tables.cy.spec.js` | scenarios > visualizations > pivot tables > should not show sub-total data after a switch to other viz type
`multiple-column-breakouts.cy.spec.ts` | scenarios > question > multiple column breakouts > current stage > viz settings > should be able to change pivot split settings when there are more than 2 breakouts
`visualizations-tabular-reproductions.cy.spec.js` | issue 32718 > should honor visibility_type of the field when the question has viz settings (metabase#32718)
`click-behavior.cy.spec.js` | scenarios > dashboard > dashboard cards > click behavior > line chart > allows setting saved question with single parameter as custom destination
`reproductions-3.cy.spec.js` | issue 37374 > should allow to change the viz type to pivot without data access (metabase#37374)
`pivot_tables.cy.spec.js` | scenarios > visualizations > pivot tables > should work with custom mapping of display values (metabase#14985)
`reproductions.cy.spec.ts` | issue 46221 > should retain the same collection name between ad-hoc question based on a model and a model itself (metabase#46221)
`click-behavior.cy.spec.js` | scenarios > dashboard > dashboard cards > click behavior > line chart > allows setting saved question with multiple parameters as custom destination
`pivot_tables.cy.spec.js` | scenarios > visualizations > pivot tables > should be possible to switch between notebook and simple views when pivot table is the visualization (metabase#39504)
`reproductions-3.cy.spec.js` | issue 44637 > should not crash when rendering a line/bar chart with empty results (metabase#44637)
and more... | and more...

author_association: CONTRIBUTOR