From ff7e29cb19403f67bacb857ed9c8d9799345ec38 Mon Sep 17 00:00:00 2001
From: Ariya Hidayat <ariya@metabase.com>
Date: Thu, 6 May 2021 16:49:26 -0700
Subject: [PATCH] Basic type inference for custom expression (#15940)

* Make it work with MBQL instead

* Update frontend/test/metabase/lib/expressions/typeinferencer.unit.spec.js

Co-authored-by: flamber <1447303+flamber@users.noreply.github.com>
---
 .../lib/expressions/typeinferencer.js         | 54 +++++++++++++
 .../expressions/typeinferencer.unit.spec.js   | 80 +++++++++++++++++++
 2 files changed, 134 insertions(+)
 create mode 100644 frontend/src/metabase/lib/expressions/typeinferencer.js
 create mode 100644 frontend/test/metabase/lib/expressions/typeinferencer.unit.spec.js

diff --git a/frontend/src/metabase/lib/expressions/typeinferencer.js b/frontend/src/metabase/lib/expressions/typeinferencer.js
new file mode 100644
index 00000000000..25d13256dc8
--- /dev/null
+++ b/frontend/src/metabase/lib/expressions/typeinferencer.js
@@ -0,0 +1,54 @@
+import { MBQL_CLAUSES } from "./config";
+import { OPERATOR as OP } from "./tokenizer";
+
+export const MONOTYPE = {
+  Undefined: "undefined",
+  Number: "number",
+  String: "string",
+  Boolean: "boolean",
+};
+
+export function infer(mbql) {
+  if (!Array.isArray(mbql)) {
+    return typeof mbql;
+  }
+  const op = mbql[0];
+  switch (op) {
+    case OP.Plus:
+    case OP.Minus:
+    case OP.Star:
+    case OP.Slash:
+      return MONOTYPE.Number;
+
+    case OP.Not:
+    case OP.And:
+    case OP.Or:
+    case OP.Equal:
+    case OP.NotEqual:
+    case OP.GreaterThan:
+    case OP.GreaterThanEqual:
+    case OP.LessThan:
+    case OP.LessThanEqual:
+      return MONOTYPE.Boolean;
+  }
+
+  if (op === "case" || op === "coalesce") {
+    // TODO
+    return MONOTYPE.Undefined;
+  }
+
+  const func = MBQL_CLAUSES[op];
+  if (func) {
+    const returnType = func.type;
+    switch (returnType) {
+      case "object":
+        return MONOTYPE.Undefined;
+      case "aggregation":
+        return MONOTYPE.Number;
+      default:
+        return returnType;
+    }
+  }
+
+  return MONOTYPE.Undefined;
+}
diff --git a/frontend/test/metabase/lib/expressions/typeinferencer.unit.spec.js b/frontend/test/metabase/lib/expressions/typeinferencer.unit.spec.js
new file mode 100644
index 00000000000..9fe75a94655
--- /dev/null
+++ b/frontend/test/metabase/lib/expressions/typeinferencer.unit.spec.js
@@ -0,0 +1,80 @@
+import { compile } from "metabase/lib/expressions/compile";
+import { infer } from "metabase/lib/expressions/typeinferencer";
+
+describe("metabase/lib/expressions/typeinferencer", () => {
+  function resolve(kind, name) {
+    return [kind, name];
+  }
+  function compileAs(source, startRule) {
+    let mbql = null;
+    try {
+      mbql = compile({ source, startRule, resolve });
+    } catch (e) {}
+    return mbql;
+  }
+
+  // workaround the limitation of the parsing expecting a strict top-level grammar rule
+  function tryCompile(source) {
+    let mbql = compileAs(source, "expression");
+    if (!mbql) {
+      mbql = compileAs(source, "boolean");
+    }
+    return mbql;
+  }
+
+  function type(expression) {
+    return infer(tryCompile(expression));
+  }
+
+  it("should infer the type of primitives", () => {
+    expect(type("0")).toEqual("number");
+    expect(type("1")).toEqual("number");
+    expect(type("3.14159")).toEqual("number");
+    expect(type('"Hola"')).toEqual("string");
+    expect(type("'Bonjour!'")).toEqual("string");
+  });
+
+  it("should infer the result of arithmetic operations", () => {
+    expect(type("[Price] + [Tax]")).toEqual("number");
+    expect(type("1.15 * [Total]")).toEqual("number");
+  });
+
+  it("should infer the result of comparisons", () => {
+    expect(type("[Discount] > 0")).toEqual("boolean");
+    expect(type("[Revenue] <= [Limit] * 2")).toEqual("boolean");
+    expect(type("1 != 2")).toEqual("boolean");
+  });
+
+  it("should infer the result of logical operations", () => {
+    expect(type("NOT [Deal]")).toEqual("boolean");
+    expect(type("[A] OR [B]")).toEqual("boolean");
+    expect(type("[X] AND [Y]")).toEqual("boolean");
+    expect(type("[Rating] < 3 AND [Price] > 100")).toEqual("boolean");
+  });
+
+  it("should infer parenthesized subexpression", () => {
+    expect(type("(3.14159)")).toEqual("number");
+    expect(type('((((("Hola")))))')).toEqual("string");
+    expect(type("NOT ([Discount] > 0)")).toEqual("boolean");
+  });
+
+  it("should infer the result of numeric functions", () => {
+    expect(type("SQRT(2)")).toEqual("number");
+    expect(type("ABS([Latitude])")).toEqual("number");
+    expect(type("FLOOR([Total] / 2.45)")).toEqual("number");
+  });
+
+  it("should infer the result of string functions", () => {
+    expect(type("Ltrim([Name])")).toEqual("string");
+    expect(type("Concat(Upper([LastN]), [FirstN])")).toEqual("string");
+    expect(type("SUBSTRING([Product], 0, 3)")).toEqual("string");
+    expect(type("Length([Category])")).toEqual("number");
+    expect(type("Length([Category]) > 0")).toEqual("boolean");
+  });
+
+  it.skip("should infer the result of CASE", () => {
+    expect(type("CASE([X], 1, 2)")).toEqual("number");
+    expect(type("CASE([Y], 'this', 'that')")).toEqual("string");
+    expect(type("CASE(BigSale, Price>100, Price>200)")).toEqual("boolean");
+  });
+});
-- 
GitLab