From 9ff09f00d143c86682454f2ab6a30b64b6c7ff09 Mon Sep 17 00:00:00 2001 From: Tom Robinson <tlrobinson@gmail.com> Date: Sat, 10 Dec 2016 10:22:01 -0800 Subject: [PATCH] Support for metrics and custom fields in expressions --- .../src/metabase/lib/expressions/formatter.js | 100 +++++++------- .../src/metabase/lib/expressions/index.js | 22 +++- .../src/metabase/lib/expressions/parser.js | 74 +++++++---- .../components/AggregationPopover.jsx | 1 + .../components/AggregationWidget.jsx | 4 +- .../query_builder/components/FieldName.jsx | 9 +- .../expressions/ExpressionEditorTextfield.jsx | 20 ++- .../components/expressions/Expressions.jsx | 5 +- frontend/test/unit/lib/expressions.spec.js | 123 ------------------ .../unit/lib/expressions/formatter.spec.js | 54 ++++++++ .../test/unit/lib/expressions/parser.spec.js | 2 +- 11 files changed, 202 insertions(+), 212 deletions(-) create mode 100644 frontend/test/unit/lib/expressions/formatter.spec.js diff --git a/frontend/src/metabase/lib/expressions/formatter.js b/frontend/src/metabase/lib/expressions/formatter.js index c1828428602..e896b0c82a9 100644 --- a/frontend/src/metabase/lib/expressions/formatter.js +++ b/frontend/src/metabase/lib/expressions/formatter.js @@ -1,61 +1,71 @@ import _ from "underscore"; -import { VALID_OPERATORS, VALID_AGGREGATIONS, isValidArg, isField, isExpression, normalizeName } from "../expressions"; -import { AggregationClause } from "../query"; +import { + VALID_OPERATORS, VALID_AGGREGATIONS, + isField, isMath, isMetric, isAggregation, isExpressionReference, + formatMetricName, formatFieldName, formatExpressionName +} from "../expressions"; -function formatField(fieldRef, { fields }) { - const fieldId = fieldRef[1]; - const field = _.findWhere(fields, {id: fieldId}); - - if (!field) throw 'field with ID does not exist: ' + fieldId; - - let displayName = field.display_name; - return displayName.indexOf(' ') === -1 ? displayName : ('"' + displayName + '"'); +// convert a MBQL expression back into an expression string +export function format(expr, { + tableMetadata = {}, + customFields = {}, + operators = VALID_OPERATORS, + aggregations = VALID_AGGREGATIONS +}, parens = false) { + const info = { tableMetadata, customFields, operators, aggregations }; + if (typeof expr === "number") { + return formatLiteral(expr); + } + if (isField(expr)) { + return formatField(expr, info); + } + if (isMetric(expr)) { + return formatMetric(expr, info); + } + if (isMath(expr)) { + return formatMath(expr, info, parens); + } + if (isAggregation(expr)) { + return formatAggregation(expr, info); + } + if (isExpressionReference(expr)) { + return formatExpressionReference(expr, info); + } + throw new Error("Unknown expression " + JSON.stringify(expr)); } -function formatMetric(metricRef, { metrics }) { - const metricId = metricRef[1]; - const metric = _.findWhere(metrics, { id: metricId }); - - if (!metric) throw 'metric with ID does not exist: ' + metricId; - - return normalizeName(metric.name) + "()"; +function formatLiteral(expr) { + return JSON.stringify(expr); } -function formatNestedExpression(expression, tableMetadata, parens = false) { - let formattedExpression = format(expression, tableMetadata); - if (VALID_OPERATORS.has(expression[0]) && parens) { - return '(' + formattedExpression + ')'; - } else { - return formattedExpression; +function formatField([, fieldId], { tableMetadata: { fields } }) { + const field = _.findWhere(fields, { id: fieldId }); + if (!field) { + throw 'field with ID does not exist: ' + fieldId; } + return formatFieldName(field.display_name); } -function formatArg(arg, tableMetadata, parens = false) { - if (!isValidArg(arg)) throw 'Invalid expression argument:' + arg; - - return isField(arg) ? formatField(arg, tableMetadata) : - isExpression(arg) ? formatNestedExpression(arg, tableMetadata, parens) : - typeof arg === 'number' ? arg : - null; +function formatMetric([, metricId], { tableMetadata: { metrics } }) { + const metric = _.findWhere(metrics, { id: metricId }); + if (!metric) { + throw 'metric with ID does not exist: ' + metricId; + } + return formatMetricName(metric.name) + "()"; } -/// convert a parsed expression back into an expression string -export function format(expression, tableMetadata = {}) { - const { operators = VALID_OPERATORS, aggregations = VALID_AGGREGATIONS } = tableMetadata; +function formatExpressionReference([, expressionName]) { + return formatExpressionName(expressionName); +} - if (!expression) return null; - if (!isExpression(expression)) throw 'Invalid expression: ' + expression; +function formatMath([operator, ...args], info, parens) { + let formatted = args.map(arg => format(arg, info, true)).join(` ${operator} `) + return parens ? `(${formatted})` : formatted; +} - const [op, ...args] = expression; - if (AggregationClause.isMetric(expression)) { - return formatMetric(expression, tableMetadata) - } else if (operators.has(op)) { - return args.map(arg => formatArg(arg, tableMetadata, true)).join(` ${op} `) - } else if (aggregations.has(op)) { - return `${aggregations.get(op)}(${args.map(arg => formatArg(arg, tableMetadata, false)).join(", ")})`; - } else { - throw new Error("Unknown clause " + op); - } +function formatAggregation([aggregation, ...args], info) { + const { aggregations } = info; + return `${aggregations.get(aggregation)}(${args.map(arg => format(arg, info)).join(", ")})`; } diff --git a/frontend/src/metabase/lib/expressions/index.js b/frontend/src/metabase/lib/expressions/index.js index 73c8cf3c8da..05b2e156484 100644 --- a/frontend/src/metabase/lib/expressions/index.js +++ b/frontend/src/metabase/lib/expressions/index.js @@ -21,15 +21,24 @@ export const VALID_AGGREGATIONS = new Map(Object.entries({ "max": "Max" })); +export function formatMetricName(metricName) { + return titleize(metricName).replace(/\W+/g, "") +} + +export function formatFieldName(fieldName) { + return /^\w+$/.test(fieldName) ? + fieldName : + JSON.stringify(fieldName); +} -export function normalizeName(name) { - return titleize(name).replace(/\W+/g, "") +export function formatExpressionName(name) { + return formatFieldName(name); } // move to query lib export function isExpression(expr) { - return isMath(expr) || isAggregation(expr) || isMetric(expr); + return isMath(expr) || isAggregation(expr) || isField(expr) || isMetric(expr) || isExpressionReference(expr); } export function isField(expr) { @@ -37,7 +46,8 @@ export function isField(expr) { } export function isMetric(expr) { - return Array.isArray(expr) && expr.length === 2 && mbqlEq(expr[0], 'metric') && typeof expr[1] === 'number'; + // case sensitive, unlike most mbql + return Array.isArray(expr) && expr.length === 2 && expr[0] === "METRIC" && typeof expr[1] === 'number'; } export function isMath(expr) { @@ -48,6 +58,10 @@ export function isAggregation(expr) { return Array.isArray(expr) && VALID_AGGREGATIONS.has(expr[0]) && _.all(expr.slice(1), isValidArg); } +export function isExpressionReference(expr) { + return Array.isArray(expr) && expr.length === 2 && mbqlEq(expr[0], 'expression') && typeof expr[1] === 'string'; +} + export function isValidArg(arg) { return isExpression(arg) || isField(arg) || typeof arg === 'number'; } diff --git a/frontend/src/metabase/lib/expressions/parser.js b/frontend/src/metabase/lib/expressions/parser.js index a04c92b39d4..399487668cb 100644 --- a/frontend/src/metabase/lib/expressions/parser.js +++ b/frontend/src/metabase/lib/expressions/parser.js @@ -1,7 +1,7 @@ const { Lexer, Parser, extendToken, getImage } = require("chevrotain"); const _ = require("underscore"); -import { VALID_AGGREGATIONS, normalizeName } from "../expressions"; +import { VALID_AGGREGATIONS, formatFieldName, formatMetricName, formatExpressionName } from "../expressions"; export const AGGREGATION_ARITY = new Map([ ["Count", 0], @@ -54,10 +54,10 @@ const ExpressionsLexer = new Lexer(allTokens); class ExpressionsParser extends Parser { - constructor(input, tableMetadata) { + constructor(input, options) { super(input, allTokens, { recoveryEnabled: false }); - this._tableMetadata = tableMetadata; + this._options = options; let $ = this; @@ -131,7 +131,11 @@ class ExpressionsParser extends Parser { let metricName = $.CONSUME(Identifier).image; $.CONSUME(LParen); $.CONSUME(RParen); - return ["METRIC", this.getMetricIdForName(metricName)]; + const metric = this.getMetricForName(metricName); + if (metric != null) { + return ["METRIC", metric.id]; + } + throw new Error("Unknown metric \"" + metricName + "\""); }); $.RULE("fieldExpression", () => { @@ -139,7 +143,15 @@ class ExpressionsParser extends Parser { {ALT: () => JSON.parse($.CONSUME(StringLiteral).image) }, {ALT: () => $.CONSUME(Identifier).image } ]); - return ["field-id", this.getFieldIdForName(fieldName)]; + const field = this.getFieldForName(fieldName); + if (field != null) { + return ["field-id", field.id]; + } + const expression = this.getExpressionForName(fieldName); + if (expression != null) { + return ["expression", fieldName]; + } + throw new Error("Unknown field \"" + fieldName + "\""); }); $.RULE("atomicExpression", (outsideAggregation) => { @@ -166,24 +178,19 @@ class ExpressionsParser extends Parser { Parser.performSelfAnalysis(this); } - getFieldIdForName(fieldName) { - const fields = this._tableMetadata && this._tableMetadata.fields || []; - for (const field of fields) { - if (field.display_name.toLowerCase() === fieldName.toLowerCase()) { - return field.id; - } - } - throw new Error("Unknown field \"" + fieldName + "\""); + getFieldForName(fieldName) { + const fields = this._options.tableMetadata && this._options.tableMetadata.fields; + return _.findWhere(fields, { display_name: fieldName }); } - getMetricIdForName(metricName) { - const metrics = this._tableMetadata && this._tableMetadata.metrics || []; - for (const metric of metrics) { - if (normalizeName(metric.name).toLowerCase() === metricName.toLowerCase()) { - return metric.id; - } - } - throw new Error("Unknown metric \"" + metricName + "\""); + getExpressionForName(expressionName) { + const customFields = this._options && this._options.customFields; + return customFields[expressionName]; + } + + getMetricForName(metricName) { + const metrics = this._options.tableMetadata && this._options.tableMetadata.metrics; + return _.find(metrics, (metric) => formatMetricName(metric.name) === metricName); } } @@ -196,11 +203,12 @@ function getTokenSource(TokenClass) { return TokenClass.PATTERN.source.replace(/^\\/, ""); } -export function compile(source, tableMetadata, { startRule } = {}) { +export function compile(source, options = {}) { if (!source) { return []; } - const parser = new ExpressionsParser(ExpressionsLexer.tokenize(source).tokens, tableMetadata); + const { startRule } = options; + const parser = new ExpressionsParser(ExpressionsLexer.tokenize(source).tokens, options); const expression = parser[startRule](); if (parser.errors.length > 0) { throw parser.errors; @@ -210,7 +218,12 @@ export function compile(source, tableMetadata, { startRule } = {}) { // No need for more than one instance. const parserInstance = new ExpressionsParser([]) -export function suggest(source, tableMetadata, { startRule, index = source.length } = {}) { +export function suggest(source, { + tableMetadata, + customFields, + startRule, + index = source.length +} = {}) { const partialSource = source.slice(0, index); const lexResult = ExpressionsLexer.tokenize(partialSource); if (lexResult.errors.length > 0) { @@ -270,9 +283,14 @@ export function suggest(source, tableMetadata, { startRule, index = source.lengt finalSuggestions.push(...tableMetadata.fields.map(field => ({ type: "fields", name: field.display_name, - text: /^\w+$/.test(field.display_name) ? - field.display_name + " " : // need a space to terminate identifier - JSON.stringify(field.display_name), + text: formatFieldName(field.display_name) + " ", + prefixTrim: /\w+$/, + postfixTrim: /^\w+\s*/ + }))) + finalSuggestions.push(...Object.keys(customFields).map(expressionName => ({ + type: "fields", + name: expressionName, + text: formatExpressionName(expressionName) + " ", prefixTrim: /\w+$/, postfixTrim: /^\w+\s*/ }))) @@ -295,7 +313,7 @@ export function suggest(source, tableMetadata, { startRule, index = source.lengt finalSuggestions.push(...tableMetadata.metrics.map(metric => ({ type: "metrics", name: metric.name, - text: normalizeName(metric.name) + "() ", + text: formatMetricName(metric.name) + "() ", prefixTrim: /\w+$/, postfixTrim: /^\w+\(\)?/ }))) diff --git a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx index c9781c78a54..f49057a2d29 100644 --- a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx +++ b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx @@ -179,6 +179,7 @@ export default class AggregationPopover extends Component { startRule="aggregation" expression={aggregation} tableMetadata={tableMetadata} + customFields={this.props.customFields} onChange={(parsedExpression) => this.setState({aggregation: parsedExpression, error: null})} onError={(errorMessage) => this.setState({error: errorMessage})} /> diff --git a/frontend/src/metabase/query_builder/components/AggregationWidget.jsx b/frontend/src/metabase/query_builder/components/AggregationWidget.jsx index c7f2911c195..d9c526f7320 100644 --- a/frontend/src/metabase/query_builder/components/AggregationWidget.jsx +++ b/frontend/src/metabase/query_builder/components/AggregationWidget.jsx @@ -89,9 +89,9 @@ export default class AggregationWidget extends Component { } renderCustomAggregation() { - const { aggregation, tableMetadata } = this.props; + const { aggregation, tableMetadata, customFields } = this.props; return ( - <span className="View-section-aggregation QueryOption p1">{aggregation ? format(aggregation, tableMetadata) : "Choose an aggregation"}</span> + <span className="View-section-aggregation QueryOption p1">{aggregation ? format(aggregation, { tableMetadata, customFields }) : "Choose an aggregation"}</span> ); } diff --git a/frontend/src/metabase/query_builder/components/FieldName.jsx b/frontend/src/metabase/query_builder/components/FieldName.jsx index c8189adc0d3..d21e0f44cf5 100644 --- a/frontend/src/metabase/query_builder/components/FieldName.jsx +++ b/frontend/src/metabase/query_builder/components/FieldName.jsx @@ -37,9 +37,12 @@ export default class FieldName extends Component { parts.push(<span key={"fkName"+index}>{stripId(fkField.display_name)}</span>); parts.push(<span key={"fkIcon"+index} className="px1"><Icon name="connections" size={10} /></span>); } - // target field itself - // using i.getIn to avoid exceptions when field is undefined - parts.push(<span key="field">{Query.getFieldPathName(fieldTarget.field.id, fieldTarget.table)}</span>); + if (fieldTarget.field.id != null) { + parts.push(<span key="field">{Query.getFieldPathName(fieldTarget.field.id, fieldTarget.table)}</span>); + } else { + // expressions, etc + parts.push(<span key="field">{fieldTarget.field.display_name}</span>); + } // datetime-field unit if (fieldTarget.unit != null) { parts.push(<span key="unit">{": " + formatBucketing(fieldTarget.unit)}</span>); diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx index 64e02385ede..dc5e8585aae 100644 --- a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx +++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx @@ -32,6 +32,7 @@ export default class ExpressionEditorTextfield extends Component { static propTypes = { expression: PropTypes.array, // should be an array like [parsedExpressionObj, expressionString] tableMetadata: PropTypes.object.isRequired, + customFields: PropTypes.object, onChange: PropTypes.func.isRequired, onError: PropTypes.func.isRequired, startRule: PropTypes.string.isRequired @@ -51,12 +52,17 @@ export default class ExpressionEditorTextfield extends Component { // we only refresh our state if we had no previous state OR if our expression or table has changed if (!this.state || this.props.expression != newProps.expression || this.props.tableMetadata != newProps.tableMetadata) { let parsedExpression = newProps.expression; - let expressionString = format(newProps.expression, this.props.tableMetadata); + let expressionString = format(newProps.expression, { + tableMetadata: newProps.tableMetadata, + customFields: newProps.customFields, + }); let expressionErrorMessage = null; let suggestions = []; try { if (expressionString) { - compile(expressionString, newProps.tableMetadata, { + compile(expressionString, { + tableMetadata: newProps.tableMetadata, + customFields: newProps.customFields, startRule: newProps.startRule }); } @@ -179,7 +185,9 @@ export default class ExpressionEditorTextfield extends Component { let parsedExpression; try { - parsedExpression = compile(expressionString, this.props.tableMetadata, { + parsedExpression = compile(expressionString, { + tableMetadata: this.props.tableMetadata, + customFields: this.props.customFields, startRule: this.props.startRule }) } catch (e) { @@ -187,7 +195,9 @@ export default class ExpressionEditorTextfield extends Component { console.error("expression error:", expressionErrorMessage); } try { - suggestions = suggest(expressionString, this.props.tableMetadata, { + suggestions = suggest(expressionString, { + tableMetadata: this.props.tableMetadata, + customFields: this.props.customFields, startRule: this.props.startRule, index: inputElement.selectionStart }) @@ -244,7 +254,7 @@ export default class ExpressionEditorTextfield extends Component { </li> , <li style={{ paddingTop: "2px", paddingBottom: "2px" }} - className={cx("cursor-pointer", {"text-bold text-brand": i === this.state.highlightedSuggestion})} + className={cx("cursor-pointer text-brand-hover", {"text-bold text-brand": i === this.state.highlightedSuggestion})} onMouseDownCapture={(e) => this.onSuggestionMouseDown(e, i)} > { suggestion.prefixLength ? diff --git a/frontend/src/metabase/query_builder/components/expressions/Expressions.jsx b/frontend/src/metabase/query_builder/components/expressions/Expressions.jsx index 05fb7b84ddb..f2a1360e225 100644 --- a/frontend/src/metabase/query_builder/components/expressions/Expressions.jsx +++ b/frontend/src/metabase/query_builder/components/expressions/Expressions.jsx @@ -32,7 +32,10 @@ export default class Expressions extends Component { { sortedNames && sortedNames.map(name => <div key={name} className="pb1 text-brand text-bold cursor-pointer flex flex-row align-center justify-between" onClick={() => onEditExpression(name)}> <span>{name}</span> - <Tooltip tooltip={format(expressions[name], this.props.tableMetadata)}> + <Tooltip tooltip={format(expressions[name], { + tableMetadata: this.props.tableMetadata, + customFields: expressions + })}> <span className="QuestionTooltipTarget" /> </Tooltip> </div> diff --git a/frontend/test/unit/lib/expressions.spec.js b/frontend/test/unit/lib/expressions.spec.js index 88cd5f87697..e69de29bb2d 100644 --- a/frontend/test/unit/lib/expressions.spec.js +++ b/frontend/test/unit/lib/expressions.spec.js @@ -1,123 +0,0 @@ -import _ from "underscore"; -import { formatExpression, parseExpressionString, tokenAtPosition, tokensToExpression } from "metabase/lib/expressions"; - -const mockFields = [ - {id: 1, display_name: "A"}, - {id: 2, display_name: "B"}, - {id: 3, display_name: "C"}, - {id: 10, display_name: "Toucan Sam"} -]; - -const mathOperators = new Set(['+', '-', '*', '/']); - -const parsedMathOperators = { - "+": { value: '+', start: 2, end: 3, parsedValue: '+' }, - "-": { value: '-', start: 2, end: 3, parsedValue: '-' }, - "*": { value: '*', start: 2, end: 3, parsedValue: '*' }, - "/": { value: '/', start: 2, end: 3, parsedValue: '/' } -} - -function stripStartEnd(list) { - return list.map(i => { - delete i.start; - delete i.end; - - if (_.isArray(i.value)) { - i.value = stripStartEnd(i.value); - } - - return i; - }); -} - - -describe("parseExpressionString", () => { - it("should return empty array for null or empty string", () => { - expect(parseExpressionString()).toEqual([]); - expect(parseExpressionString(null)).toEqual([]); - expect(parseExpressionString("")).toEqual([]); - }); - - // simplest possible expression - it("can parse single operator math", () => { - expect(stripStartEnd(parseExpressionString("A - B", mockFields, mathOperators))) - .toEqual([ - { value: 'A', suggestions: [mockFields[0], mockFields[3]], parsedValue: [ 'field-id', 1 ] }, - { value: '-', parsedValue: '-' }, - { value: 'B', suggestions: [], parsedValue: [ 'field-id', 2 ] } - ]); - }); - - it("can parse function with no arguments", () => { - expect(stripStartEnd(parseExpressionString("count()", mockFields, mathOperators))) - .toEqual([ - { value: 'count', parsedValue: [ 'count' ] } - ]); - }); - - // quoted field name w/ a space in it - it("can parse a field with quotes and spaces", () => { - expect(stripStartEnd(parseExpressionString("\"Toucan Sam\" + B", mockFields, mathOperators))) - .toEqual([ - { value: 'Toucan Sam', suggestions: [], parsedValue: [ 'field-id', 10 ] }, - { value: '+', parsedValue: '+' }, - { value: 'B', suggestions: [], parsedValue: [ 'field-id', 2 ] } - ]); - }); - - // parentheses / nested parens - it("can parse expressions with parentheses", () => { - expect(stripStartEnd(parseExpressionString("\"Toucan Sam\" + (A * (B / C))", mockFields, mathOperators))) - .toEqual([ - { value: 'Toucan Sam', suggestions: [], parsedValue: [ 'field-id', 10 ] }, - { value: '+', parsedValue: '+' }, - { value: [{ value: 'A', suggestions: [mockFields[0], mockFields[3]], parsedValue: [ 'field-id', 1 ] }, - { value: '*', parsedValue: '*' }, - { value: [{ value: 'B', suggestions: [], parsedValue: [ 'field-id', 2 ] }, - { value: '/', parsedValue: '/' }, - { value: 'C', suggestions: [mockFields[2], mockFields[3]], parsedValue: [ 'field-id', 3 ] }], - isParent: true}], - isParent: true } - ]); - }); - - // fks - // multiple tables with the same field name resolution -}); - - -describe("formatExpression", () => { - it("should return empty array for null or empty string", () => { - expect(parseExpressionString()).toEqual([]); - expect(parseExpressionString(null)).toEqual([]); - expect(parseExpressionString("")).toEqual([]); - }); - - it("can format simple expressions", () => { - expect(formatExpression(["+", ["field-id", 1], ["field-id", 2]], mockFields)).toEqual("A + B"); - }); - - it("can format expressions with parentheses", () => { - expect(formatExpression(["+", ["/", ["field-id", 1], ["field-id", 2]], ["field-id", 3]], mockFields)).toEqual("(A / B) + C"); - expect(formatExpression(["+", ["/", ["field-id", 1], ["*", ["field-id", 2], ["field-id", 2]]], ["field-id", 3]], mockFields)).toEqual("(A / (B * B)) + C"); - }); - - it("quotes fields with spaces in them", () => { - expect(formatExpression(["+", ["/", ["field-id", 1], ["field-id", 10]], ["field-id", 3]], mockFields)).toEqual("(A / \"Toucan Sam\") + C"); - }); - - it("format aggregations", () => { - expect(formatExpression(["count"], mockFields)).toEqual("count()"); - expect(formatExpression(["sum", ["field-id", 1]], mockFields)).toEqual("sum(A)"); - }); - - it("nested aggregation", () => { - expect(formatExpression(["+", 1, ["count"]], mockFields)).toEqual("1 + count()"); - expect(formatExpression(["/", ["sum", ["field-id", 1]], ["count"]], mockFields)).toEqual("sum(A) / count()"); - }); - - it("aggregation with expressions", () => { - // TODO: get rid of the extra parens - expect(formatExpression(["sum", ["/", ["field-id", 1], ["field-id", 2]]], mockFields)).toEqual("sum((A / B))"); - }); -}); diff --git a/frontend/test/unit/lib/expressions/formatter.spec.js b/frontend/test/unit/lib/expressions/formatter.spec.js new file mode 100644 index 00000000000..00a65395f86 --- /dev/null +++ b/frontend/test/unit/lib/expressions/formatter.spec.js @@ -0,0 +1,54 @@ +import { format } from "metabase/lib/expressions/formatter"; + +const mockMetadata = { + tableMetadata: { + fields: [ + {id: 1, display_name: "A"}, + {id: 2, display_name: "B"}, + {id: 3, display_name: "C"}, + {id: 10, display_name: "Toucan Sam"} + ], + metrics: [ + {id: 1, name: "foo bar"}, + ] + } +} + +fdescribe("lib/expressions/parser", () => { + describe("format", () => { + it("can format simple expressions", () => { + expect(format(["+", ["field-id", 1], ["field-id", 2]], mockMetadata)).toEqual("A + B"); + }); + + it("can format expressions with parentheses", () => { + expect(format(["+", ["/", ["field-id", 1], ["field-id", 2]], ["field-id", 3]], mockMetadata)).toEqual("(A / B) + C"); + expect(format(["+", ["/", ["field-id", 1], ["*", ["field-id", 2], ["field-id", 2]]], ["field-id", 3]], mockMetadata)).toEqual("(A / (B * B)) + C"); + }); + + it("quotes fields with spaces in them", () => { + expect(format(["+", ["/", ["field-id", 1], ["field-id", 10]], ["field-id", 3]], mockMetadata)).toEqual("(A / \"Toucan Sam\") + C"); + }); + + it("format aggregations", () => { + expect(format(["count"], mockMetadata)).toEqual("Count()"); + expect(format(["sum", ["field-id", 1]], mockMetadata)).toEqual("Sum(A)"); + }); + + it("nested aggregation", () => { + expect(format(["+", 1, ["count"]], mockMetadata)).toEqual("1 + Count()"); + expect(format(["/", ["sum", ["field-id", 1]], ["count"]], mockMetadata)).toEqual("Sum(A) / Count()"); + }); + + it("aggregation with expressions", () => { + expect(format(["sum", ["/", ["field-id", 1], ["field-id", 2]]], mockMetadata)).toEqual("Sum(A / B)"); + }); + + it("expression with metric", () => { + expect(format(["+", 1, ["METRIC", 1]], mockMetadata)).toEqual("1 + FooBar()"); + }); + + it("expression with custom field", () => { + expect(format(["+", 1, ["sum", ["expression", "foo bar"]]], mockMetadata)).toEqual("1 + Sum(\"foo bar\")"); + }); + }); +}); diff --git a/frontend/test/unit/lib/expressions/parser.spec.js b/frontend/test/unit/lib/expressions/parser.spec.js index b9ec57bff5e..ca5ac4addc3 100644 --- a/frontend/test/unit/lib/expressions/parser.spec.js +++ b/frontend/test/unit/lib/expressions/parser.spec.js @@ -8,7 +8,7 @@ const mockFields = [ {id: 10, display_name: "Toucan Sam"} ]; -fdescribe("lib/expressions/parser", () => { +describe("lib/expressions/parser", () => { describe("compile()", () => { it("should return empty array for null or empty string", () => { expect(compile()).toEqual([]); -- GitLab