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