Repository: metabase/metabase
Pull request #55737: Enable type checking in the expression editor
Author: login: ranquild
Labels: name: no-backport
description: Do not backport this PR to any branch
name: .Team/Querying
Created: 2025-03-26T00:33:04Z | Merged: 2025-03-28T13:55:26Z

Re-enable strict type checking in the expression editor when editing the expression. When there is a type error, we show `Invalid expression`.  If we want something more user-friendly that's  a separate piece of work; still, I don't expect that many people try to do something which is incorrect type-wise. 

Changes:
- Enables type-checking only within `diagnose-expression`. If there is an invalid expression in the query, it won't be dropped automatically (as in earlier v50 versions).
- Drops basic type checking done by the FE. Now the single source of truth for expression types is MBQL lib, i.e. the schemas. FE expression type checking caused constant issues.

<img width="768" alt="Screenshot 2025-03-25 at 20 41 42" src="https://github.com/user-attachments/assets/966d563a-c61d-46bd-a392-c29ba9f1461b" />



Diff:
diff --git a/e2e/test/scenarios/filters-reproductions/filters-reproductions.cy.spec.js b/e2e/test/scenarios/filters-reproductions/filters-reproductions.cy.spec.js
index 66b928e75a7d..1b8f08d314bc 100644
--- a/e2e/test/scenarios/filters-reproductions/filters-reproductions.cy.spec.js
+++ b/e2e/test/scenarios/filters-reproductions/filters-reproductions.cy.spec.js
@@ -815,8 +815,7 @@ describe("issue 27123", () => {
   });
 });
 
-// TODO: Unskip this test when we bring back expression type checking. See #31877.
-describe.skip("issue 29094", () => {
+describe("issue 29094", () => {
   beforeEach(() => {
     H.restore();
     cy.signInAsNormalUser();
diff --git a/e2e/test/scenarios/filters/filter.cy.spec.js b/e2e/test/scenarios/filters/filter.cy.spec.js
index f49a333cd8a4..7ea6798c3291 100644
--- a/e2e/test/scenarios/filters/filter.cy.spec.js
+++ b/e2e/test/scenarios/filters/filter.cy.spec.js
@@ -567,9 +567,7 @@ describe("scenarios > question > filter", () => {
     H.enterCustomColumnDetails({ formula: "3.14159" });
     H.popover().within(() => {
       cy.button("Done").should("be.disabled");
-      cy.findByText("Standalone constants are not supported.").should(
-        "be.visible",
-      );
+      cy.findByText("Invalid expression").should("be.visible");
     });
   });
 
@@ -580,9 +578,7 @@ describe("scenarios > question > filter", () => {
     H.enterCustomColumnDetails({ formula: '"TheAnswer"' });
     H.popover().within(() => {
       cy.button("Done").should("be.disabled");
-      cy.findByText("Standalone constants are not supported.").should(
-        "be.visible",
-      );
+      cy.findByText("Invalid expression").should("be.visible");
     });
   });
 
diff --git a/frontend/src/metabase-lib/v1/expressions/config.ts b/frontend/src/metabase-lib/v1/expressions/config.ts
index 8c5b1a11b74e..6b58afd7e574 100644
--- a/frontend/src/metabase-lib/v1/expressions/config.ts
+++ b/frontend/src/metabase-lib/v1/expressions/config.ts
@@ -34,6 +34,7 @@ export const OPERATOR_PRECEDENCE: Record<string, number> = {
   or: 5,
 };
 
+// `type` and `args` types have no effect. Type checking is done by MBQL lib.
 export const MBQL_CLAUSES: MBQLClauseMap = {
   // aggregation functions
   count: { displayName: `Count`, type: "aggregation", args: [] },
diff --git a/frontend/src/metabase-lib/v1/expressions/resolver.js b/frontend/src/metabase-lib/v1/expressions/resolver.js
index 0ac81935839a..44053cb87582 100644
--- a/frontend/src/metabase-lib/v1/expressions/resolver.js
+++ b/frontend/src/metabase-lib/v1/expressions/resolver.js
@@ -39,38 +39,6 @@ function findMBQL(op) {
   return clause;
 }
 
-const isCompatible = (expectedType, inferredType) => {
-  if (expectedType === "any" || inferredType === "any") {
-    return true;
-  }
-  if (expectedType === inferredType) {
-    return true;
-  }
-  // if b is a string, then it can be an arg to a function that expects a datetime argument.
-  // This allows datetime string literals to work as args for functions that expect datetime types.
-  // FIXME: By doing this we are allowing string columns to be arguments to functions, which isn’t valid MBQL.
-  if (expectedType === "datetime" && inferredType === "string") {
-    return true;
-  }
-  if (
-    expectedType === "expression" &&
-    ["datetime", "number", "string", "boolean"].includes(inferredType)
-  ) {
-    return true;
-  }
-  if (expectedType === "aggregation" && inferredType === "number") {
-    return true;
-  }
-  if (expectedType === "number" && inferredType === "aggregation") {
-    return true;
-  }
-  if (expectedType === "expression" && inferredType === "aggregation") {
-    return true;
-  }
-
-  return false;
-};
-
 /**
  * @param {{
  *   expression: import("./pratt").Expr
@@ -176,12 +144,6 @@ export function resolve({
     }
 
     const { displayName, args, multiple, hasOptions, validator } = clause;
-    if (!isCompatible(type, clause.type)) {
-      throw new ResolverError(
-        t`Expecting ${type} but found function ${displayName} returning ${clause.type}`,
-        expression.node,
-      );
-    }
     if (validator) {
       const validationError = validator(...operands);
       if (validationError) {
@@ -229,11 +191,6 @@ export function resolve({
       return resolve({ expression: operand, type: args[i], fn, database });
     });
     return [op, ...resolvedOperands];
-  } else if (!isCompatible(type, typeof expression)) {
-    throw new ResolverError(
-      t`Expecting ${type} but found ${JSON.stringify(expression)}`,
-      expression.node,
-    );
   }
   return expression;
 }
diff --git a/frontend/src/metabase-lib/v1/expressions/resolver.unit.spec.ts b/frontend/src/metabase-lib/v1/expressions/resolver.unit.spec.ts
index e45712bfb7e1..aaa896ff906f 100644
--- a/frontend/src/metabase-lib/v1/expressions/resolver.unit.spec.ts
+++ b/frontend/src/metabase-lib/v1/expressions/resolver.unit.spec.ts
@@ -74,14 +74,6 @@ describe("resolve", () => {
       expect(filter(["or", P, [">", Q, 3]]).dimensions).toEqual(["Q"]);
     });
 
-    it("should reject a number literal", () => {
-      expect(() => filter("3.14159")).toThrow();
-    });
-
-    it("should reject a string literal", () => {
-      expect(() => filter('"TheAnswer"')).toThrow();
-    });
-
     it("should catch mismatched number of function parameters", () => {
       expect(() => filter(["between"])).toThrow();
       expect(() => filter(["between", Y])).toThrow();
@@ -244,21 +236,6 @@ describe("resolve", () => {
           ]),
         ).not.toThrow();
       });
-
-      it("should throw if chaining datetime functions onto functions of incompatible types", () => {
-        expect(() =>
-          expr(["trim", ["datetime-add", "2022-01-01", 1, "month"]]),
-        ).toThrow();
-      });
-
-      it("should throw if passing numbers as arguments expected to be datetime", () => {
-        expect(() => expr(["get-day", 15])).toThrow();
-        expect(() => expr(["get-day-of-week", 6])).toThrow();
-        expect(() => expr(["get-week", 52])).toThrow();
-        expect(() => expr(["get-month", 12])).toThrow();
-        expect(() => expr(["get-quarter", 3])).toThrow();
-        expect(() => expr(["get-year", 2025])).toThrow();
-      });
     });
   });
 
@@ -353,19 +330,6 @@ describe("resolve", () => {
       expect(() => expr(["case", [], { default: Y }])).toThrow();
     });
 
-    it("should reject a CASE expression with incorrect argument type", () => {
-      // CASE(X, 1, 2, 3)
-      expect(() =>
-        expr([
-          "case",
-          [
-            [X, 1],
-            [2, 3],
-          ],
-        ]),
-      ).toThrow();
-    });
-
     it("should accept a CASE expression with complex arguments", () => {
       // CASE(X, 0.5*Y, A-B)
       const def: CaseOptions = { default: ["-", A, B] };
diff --git a/src/metabase/lib/expression.cljc b/src/metabase/lib/expression.cljc
index e591c2f43b95..2e0f8b5d2087 100644
--- a/src/metabase/lib/expression.cljc
+++ b/src/metabase/lib/expression.cljc
@@ -516,33 +516,34 @@
    expression-mode     :- [:enum :expression :aggregation :filter]
    expr                :- :any
    expression-position :- [:maybe :int]]
-  (let [[validator explainer] (clojure.core/case expression-mode
-                                :expression [expression-validator expression-explainer]
-                                :aggregation [aggregation-validator aggregation-explainer]
-                                :filter [filter-validator filter-explainer])]
-    (or (when-not (validator expr)
-          (let [error (explainer expr)
-                humanized (str/join ", " (me/humanize error))]
-            {:message (i18n/tru "Type error: {0}" humanized)}))
-        (when-let [dependency-path (and (= expression-mode :expression)
-                                        expression-position
-                                        (let [exprs (expressions query stage-number)
-                                              edited-expr (nth exprs expression-position)
-                                              edited-name (expression->name edited-expr)
-                                              deps (-> (m/index-by expression->name exprs)
-                                                       (assoc edited-name expr)
-                                                       (update-vals referred-expressions))]
-                                          (cyclic-definition deps)))]
-          {:message  (i18n/tru "Cycle detected: {0}" (str/join " → " dependency-path))
-           :friendly true})
-        (when (and (= expression-mode :expression)
-                   (lib.util.match/match-one expr :offset))
-          {:message  (i18n/tru "OFFSET is not supported in custom columns")
-           :friendly true})
-        (when (and (= expression-mode :filter)
-                   (lib.util.match/match-one expr :offset))
-          {:message  (i18n/tru "OFFSET is not supported in custom filters")
-           :friendly true})
-        (when (= (first expr) :value)
-          {:message  (i18n/tru "Standalone constants are not supported.")
-           :friendly true}))))
+  (binding [lib.schema.expression/*suppress-expression-type-check?* false]
+    (let [[validator explainer] (clojure.core/case expression-mode
+                                  :expression [expression-validator expression-explainer]
+                                  :aggregation [aggregation-validator aggregation-explainer]
+                                  :filter [filter-validator filter-explainer])]
+      (or (when-not (validator expr)
+            (let [error (explainer expr)
+                  humanized (str/join ", " (me/humanize error))]
+              {:message (i18n/tru "Type error: {0}" humanized)}))
+          (when-let [dependency-path (and (= expression-mode :expression)
+                                          expression-position
+                                          (let [exprs (expressions query stage-number)
+                                                edited-expr (nth exprs expression-position)
+                                                edited-name (expression->name edited-expr)
+                                                deps (-> (m/index-by expression->name exprs)
+                                                         (assoc edited-name expr)
+                                                         (update-vals referred-expressions))]
+                                            (cyclic-definition deps)))]
+            {:message  (i18n/tru "Cycle detected: {0}" (str/join " → " dependency-path))
+             :friendly true})
+          (when (and (= expression-mode :expression)
+                     (lib.util.match/match-one expr :offset))
+            {:message  (i18n/tru "OFFSET is not supported in custom columns")
+             :friendly true})
+          (when (and (= expression-mode :filter)
+                     (lib.util.match/match-one expr :offset))
+            {:message  (i18n/tru "OFFSET is not supported in custom filters")
+             :friendly true})
+          (when (= (first expr) :value)
+            {:message  (i18n/tru "Standalone constants are not supported.")
+             :friendly true})))))
diff --git a/test/metabase/lib/expression_test.cljc b/test/metabase/lib/expression_test.cljc
index 937ace0d70b0..36a1e8a1ffa7 100644
--- a/test/metabase/lib/expression_test.cljc
+++ b/test/metabase/lib/expression_test.cljc
@@ -475,19 +475,18 @@
 (deftest ^:parallel diagnose-expression-test-2
   (testing "correct expression are accepted silently"
     (testing "type errors are reported"
-      (binding [lib.schema.expression/*suppress-expression-type-check?* false]
-        (are [mode expr] (=? {:message #"Type error: .*"}
-                             (lib.expression/diagnose-expression
-                              (lib.tu/venues-query) 0 mode
-                              (lib.convert/->pMBQL expr)
-                              #?(:clj nil :cljs js/undefined)))
-          :expression  [:/ [:field 1 {:base-type :type/Address}] 100]
+      (are [mode expr] (=? {:message #"Type error: .*"}
+                           (lib.expression/diagnose-expression
+                            (lib.tu/venues-query) 0 mode
+                            (lib.convert/->pMBQL expr)
+                            #?(:clj nil :cljs js/undefined)))
+        :expression  [:/ [:field 1 {:base-type :type/Address}] 100]
              ;; To make this test case work, the aggregation schema has to be
              ;; tighter and not allow anything. That's a bigger piece of work,
              ;; because it makes expressions and aggregations mutually recursive
              ;; or requires a large amount of duplication.
-          #_#_:aggregation [:sum [:is-empty [:field 1 {:base-type :type/Boolean}]]]
-          :filter      [:sum [:field 1 {:base-type :type/Integer}]])))))
+        #_#_:aggregation [:sum [:is-empty [:field 1 {:base-type :type/Boolean}]]]
+        :filter      [:sum [:field 1 {:base-type :type/Integer}]]))))
 
 (deftest ^:parallel diagnose-expression-test-3
   (testing "correct expression are accepted silently"
@@ -545,7 +544,7 @@
 (deftest ^:parallel diagnose-expression-literals-test
   (testing "top-level literals are not allowed"
     (let [query (lib/query meta/metadata-provider (meta/table-metadata :orders))
-          expr  [:value {:lib/uuid (str (random-uuid)) :effective-type :type/Integer} 1]]
+          expr  (lib.expression/value true)]
       (doseq [mode [:expression :filter]]
         (is (=? {:message  "Standalone constants are not supported."
                  :friendly true}


Reviews:
user: login: ranquild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T00:59:04Z
user: login: ranquild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T15:23:04Z
user: login: ranquild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T19:14:42Z
user: login: mngr
state: APPROVED
author_association: NONE
submitted_at: 2025-03-27T19:56:49Z
user: login: romeovs
body: I have one question about unrelated errors not being shown anymore but otherwise looks good!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T08:42:28Z

Review comments:
diff_hunk: @@ -516,33 +516,34 @@
    expression-mode     :- [:enum :expression :aggregation :filter]
    expr                :- :any
    expression-position :- [:maybe :int]]
-  (let [[validator explainer] (clojure.core/case expression-mode
-                                :expression [expression-validator expression-explainer]
-                                :aggregation [aggregation-validator aggregation-explainer]
-                                :filter [filter-validator filter-explainer])]
-    (or (when-not (validator expr)
-          (let [error (explainer expr)
-                humanized (str/join ", " (me/humanize error))]
-            {:message (i18n/tru "Type error: {0}" humanized)}))
-        (when-let [dependency-path (and (= expression-mode :expression)
-                                        expression-position
-                                        (let [exprs (expressions query stage-number)
-                                              edited-expr (nth exprs expression-position)
-                                              edited-name (expression->name edited-expr)
-                                              deps (-> (m/index-by expression->name exprs)
-                                                       (assoc edited-name expr)
-                                                       (update-vals referred-expressions))]
-                                          (cyclic-definition deps)))]
-          {:message  (i18n/tru "Cycle detected: {0}" (str/join " → " dependency-path))
-           :friendly true})
-        (when (and (= expression-mode :expression)
-                   (lib.util.match/match-one expr :offset))
-          {:message  (i18n/tru "OFFSET is not supported in custom columns")
-           :friendly true})
-        (when (and (= expression-mode :filter)
-                   (lib.util.match/match-one expr :offset))
-          {:message  (i18n/tru "OFFSET is not supported in custom filters")
-           :friendly true})
-        (when (= (first expr) :value)
-          {:message  (i18n/tru "Standalone constants are not supported.")
-           :friendly true}))))
+  (binding [lib.schema.expression/*suppress-expression-type-check?* false]
path: src/metabase/lib/expression.cljc
user: login: ranquild
body: This is the only new line
created_at: 2025-03-26T00:59:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,38 +39,6 @@ function findMBQL(op) {
   return clause;
 }
 
-const isCompatible = (expectedType, inferredType) => {
path: frontend/src/metabase-lib/v1/expressions/resolver.js
user: login: romeovs
body: This is great! 
created_at: 2025-03-26T07:33:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -567,9 +567,7 @@ describe("scenarios > question > filter", () => {
     H.enterCustomColumnDetails({ formula: "3.14159" });
     H.popover().within(() => {
       cy.button("Done").should("be.disabled");
-      cy.findByText("Standalone constants are not supported.").should(
-        "be.visible",
-      );
+      cy.findByText("Invalid expression").should("be.visible");
path: e2e/test/scenarios/filters/filter.cy.spec.js
user: login: romeovs
body: You mentioned that for type errors we need to do ore work to have nicer error messages, but is it not possible to preserve this error here?

created_at: 2025-03-26T07:43:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -545,7 +544,7 @@
 (deftest ^:parallel diagnose-expression-literals-test
   (testing "top-level literals are not allowed"
     (let [query (lib/query meta/metadata-provider (meta/table-metadata :orders))
-          expr  [:value {:lib/uuid (str (random-uuid)) :effective-type :type/Integer} 1]]
+          expr  (lib.expression/value true)]
path: test/metabase/lib/expression_test.cljc
user: login: ranquild
body: Previously I tried to use a raw integer for a filter, which should have been rejected. Now, with type checking, it's an invalid value. I used a boolean literal now which should be supported by both custom column and filter expressions. 
created_at: 2025-03-26T15:23:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -475,19 +475,18 @@
 (deftest ^:parallel diagnose-expression-test-2
   (testing "correct expression are accepted silently"
     (testing "type errors are reported"
-      (binding [lib.schema.expression/*suppress-expression-type-check?* false]
path: test/metabase/lib/expression_test.cljc
user: login: ranquild
body: I only removed `binding` here since it's the default behavior now
created_at: 2025-03-27T19:14:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: trunk-io[bot]
created_at: 2025-03-26T01:22:20Z
body: <!-- Trunk Test Analytics -->


[![Static Badge](https://img.shields.io/badge/116187_tests-aac5f1d9-purple)](https://app.trunk.io/metabase/flaky-tests/pr/55737?repo=metabase/metabase&commitHash=aac5f1d9b0162aa6bbe2fd053c7438e6d22aa4f6&utm_source=gh-comment) &ensp; [![Static Badge](https://img.shields.io/badge/0-failed-crimson)](https://app.trunk.io/metabase/flaky-tests/pr/55737?repo=metabase/metabase&commitHash=aac5f1d9b0162aa6bbe2fd053c7438e6d22aa4f6&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/2-flaky-red)](https://app.trunk.io/metabase/flaky-tests/pr/55737?repo=metabase/metabase&commitHash=aac5f1d9b0162aa6bbe2fd053c7438e6d22aa4f6&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/0-quarantined-yellow)](https://app.trunk.io/metabase/flaky-tests/pr/55737?repo=metabase/metabase&commitHash=aac5f1d9b0162aa6bbe2fd053c7438e6d22aa4f6&utm_source=gh-comment&runConclusion=QUARANTINED)


| Flaky Test | Failure Summary | Logs |
| --- | --- | --- |
| <code>unsubscribe-receive-email-test</code> | The unsubscribe confirmation email was sent with incorrect body. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/55737?repo=metabase/metabase&commitHash=aac5f1d9b0162aa6bbe2fd053c7438e6d22aa4f6&utm_source=gh-comment&runConclusion=FAILURE&runId=a19bf11f-50e9-5a92-94fd-82ccc0d9e5c3) |
| <code>migrate!-test</code> | The progress call during other stages did not match the expected result. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/55737?repo=metabase/metabase&commitHash=aac5f1d9b0162aa6bbe2fd053c7438e6d22aa4f6&utm_source=gh-comment&runConclusion=FAILURE&runId=4c6f5f3d-8788-54e2-b714-6a3caea774bf) |
<sub>

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

</sub>
author_association: NONE