Skip to content
Snippets Groups Projects
Unverified Commit e88e3b56 authored by Tom Robinson's avatar Tom Robinson
Browse files

Initial metrics support

parent e97f0100
No related merge requests found
frontend/src/metabase/lib/expressions/aggregation.js
import _ from "underscore"; import _ from "underscore";
import { VALID_OPERATORS, VALID_AGGREGATIONS, isValidArg, isField, isExpression } from "../expressions"; import { VALID_OPERATORS, VALID_AGGREGATIONS, isValidArg, isField, isExpression, normalizeName } from "../expressions";
import { AggregationClause } from "../query";
function formatField(fieldRef, fields) { function formatField(fieldRef, { fields }) {
let fieldID = fieldRef[1], const fieldId = fieldRef[1];
field = _.findWhere(fields, {id: fieldID}); const field = _.findWhere(fields, {id: fieldId});
if (!field) throw 'field with ID does not exist: ' + fieldID; if (!field) throw 'field with ID does not exist: ' + fieldId;
let displayName = field.display_name; let displayName = field.display_name;
return displayName.indexOf(' ') === -1 ? displayName : ('"' + displayName + '"'); return displayName.indexOf(' ') === -1 ? displayName : ('"' + displayName + '"');
} }
function formatNestedExpression(expression, fields, parens = false) { function formatMetric(metricRef, { metrics }) {
let formattedExpression = format(expression, { fields }); 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 formatNestedExpression(expression, tableMetadata, parens = false) {
let formattedExpression = format(expression, tableMetadata);
if (VALID_OPERATORS.has(expression[0]) && parens) { if (VALID_OPERATORS.has(expression[0]) && parens) {
return '(' + formattedExpression + ')'; return '(' + formattedExpression + ')';
} else { } else {
...@@ -22,25 +32,29 @@ function formatNestedExpression(expression, fields, parens = false) { ...@@ -22,25 +32,29 @@ function formatNestedExpression(expression, fields, parens = false) {
} }
} }
function formatArg(arg, fields, parens = false) { function formatArg(arg, tableMetadata, parens = false) {
if (!isValidArg(arg)) throw 'Invalid expression argument:' + arg; if (!isValidArg(arg)) throw 'Invalid expression argument:' + arg;
return isField(arg) ? formatField(arg, fields) : return isField(arg) ? formatField(arg, tableMetadata) :
isExpression(arg) ? formatNestedExpression(arg, fields, parens) : isExpression(arg) ? formatNestedExpression(arg, tableMetadata, parens) :
typeof arg === 'number' ? arg : typeof arg === 'number' ? arg :
null; null;
} }
/// convert a parsed expression back into an expression string /// convert a parsed expression back into an expression string
export function format(expression, { fields, operators = VALID_OPERATORS, aggregations = VALID_AGGREGATIONS } = {}) { export function format(expression, tableMetadata = {}) {
const { operators = VALID_OPERATORS, aggregations = VALID_AGGREGATIONS } = tableMetadata;
if (!expression) return null; if (!expression) return null;
if (!isExpression(expression)) throw 'Invalid expression: ' + expression; if (!isExpression(expression)) throw 'Invalid expression: ' + expression;
const [op, ...args] = expression; const [op, ...args] = expression;
if (operators.has(op)) { if (AggregationClause.isMetric(expression)) {
return args.map(arg => formatArg(arg, fields, true)).join(` ${op} `) return formatMetric(expression, tableMetadata)
} else if (operators.has(op)) {
return args.map(arg => formatArg(arg, tableMetadata, true)).join(` ${op} `)
} else if (aggregations.has(op)) { } else if (aggregations.has(op)) {
return `${aggregations.get(op)}(${args.map(arg => formatArg(arg, fields, false)).join(", ")})`; return `${aggregations.get(op)}(${args.map(arg => formatArg(arg, tableMetadata, false)).join(", ")})`;
} else { } else {
throw new Error("Unknown clause " + op); throw new Error("Unknown clause " + op);
} }
......
import _ from "underscore"; import _ from "underscore";
import { mbqlEq } from "../query/util";
import { titleize } from "metabase/lib/formatting";
export const VALID_OPERATORS = new Set([ export const VALID_OPERATORS = new Set([
'+', '+',
...@@ -19,14 +21,23 @@ export const VALID_AGGREGATIONS = new Map(Object.entries({ ...@@ -19,14 +21,23 @@ export const VALID_AGGREGATIONS = new Map(Object.entries({
"max": "Max" "max": "Max"
})); }));
// move to query lib
export function isField(arg) { export function normalizeName(name) {
return arg && arg.constructor === Array && arg.length === 2 && arg[0] === 'field-id' && typeof arg[1] === 'number'; return titleize(name).replace(/\W+/g, "")
} }
// move to query lib
export function isExpression(expr) { export function isExpression(expr) {
return isMath(expr) || isAggregation(expr); return isMath(expr) || isAggregation(expr) || isMetric(expr);
}
export function isField(expr) {
return Array.isArray(expr) && expr.length === 2 && mbqlEq(expr[0], 'field-id') && typeof expr[1] === 'number';
}
export function isMetric(expr) {
return Array.isArray(expr) && expr.length === 2 && mbqlEq(expr[0], 'metric') && typeof expr[1] === 'number';
} }
export function isMath(expr) { export function isMath(expr) {
......
const { Lexer, Parser, extendToken, getImage } = require("chevrotain"); const { Lexer, Parser, extendToken, getImage } = require("chevrotain");
const _ = require("underscore"); const _ = require("underscore");
import { VALID_AGGREGATIONS } from "../expressions"; import { VALID_AGGREGATIONS, normalizeName } from "../expressions";
export const AGGREGATION_ARITY = new Map([ export const AGGREGATION_ARITY = new Map([
["Count", 0], ["Count", 0],
...@@ -23,8 +23,9 @@ const Multi = extendToken("Multi", /\*/, MultiplicativeOperator); ...@@ -23,8 +23,9 @@ const Multi = extendToken("Multi", /\*/, MultiplicativeOperator);
const Div = extendToken("Div", /\//, MultiplicativeOperator); const Div = extendToken("Div", /\//, MultiplicativeOperator);
const Aggregation = extendToken("Aggregation", Lexer.NA); const Aggregation = extendToken("Aggregation", Lexer.NA);
const aggregationsTokens = Array.from(VALID_AGGREGATIONS).map(([short, expressionName]) =>
const Keyword = extendToken('Keyword', Lexer.NA); extendToken(expressionName, new RegExp(expressionName), Aggregation)
);
const Identifier = extendToken('Identifier', /\w+/); const Identifier = extendToken('Identifier', /\w+/);
var NumberLiteral = extendToken("NumberLiteral", /-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/); var NumberLiteral = extendToken("NumberLiteral", /-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/);
...@@ -37,9 +38,6 @@ const RParen = extendToken('RParen', /\)/); ...@@ -37,9 +38,6 @@ const RParen = extendToken('RParen', /\)/);
const WhiteSpace = extendToken("WhiteSpace", /\s+/); const WhiteSpace = extendToken("WhiteSpace", /\s+/);
WhiteSpace.GROUP = Lexer.SKIPPED; WhiteSpace.GROUP = Lexer.SKIPPED;
const aggregationsTokens = Array.from(VALID_AGGREGATIONS).map(([short, expressionName]) =>
extendToken(expressionName, new RegExp(expressionName), Aggregation)
);
const aggregationsMap = new Map(Array.from(VALID_AGGREGATIONS).map(([a,b]) => [b,a])); const aggregationsMap = new Map(Array.from(VALID_AGGREGATIONS).map(([a,b]) => [b,a]));
// whitespace is normally very common so it is placed first to speed up the lexer // whitespace is normally very common so it is placed first to speed up the lexer
...@@ -47,19 +45,19 @@ export const allTokens = [ ...@@ -47,19 +45,19 @@ export const allTokens = [
WhiteSpace, LParen, RParen, Comma, WhiteSpace, LParen, RParen, Comma,
Plus, Minus, Multi, Div, Plus, Minus, Multi, Div,
AdditiveOperator, MultiplicativeOperator, AdditiveOperator, MultiplicativeOperator,
StringLiteral, NumberLiteral,
Aggregation, ...aggregationsTokens, Aggregation, ...aggregationsTokens,
Keyword, Identifier StringLiteral, NumberLiteral,
Identifier
]; ];
const ExpressionsLexer = new Lexer(allTokens); const ExpressionsLexer = new Lexer(allTokens);
class ExpressionsParser extends Parser { class ExpressionsParser extends Parser {
constructor(input, fields) { constructor(input, tableMetadata) {
super(input, allTokens, { recoveryEnabled: false }); super(input, allTokens, { recoveryEnabled: false });
this._fields = fields || []; this._tableMetadata = tableMetadata;
let $ = this; let $ = this;
...@@ -106,6 +104,13 @@ class ExpressionsParser extends Parser { ...@@ -106,6 +104,13 @@ class ExpressionsParser extends Parser {
return value return value
}); });
$.RULE("aggregationOrMetricExpression", (outsideAggregation) => {
return $.OR([
{ALT: () => $.SUBRULE($.aggregationExpression, [outsideAggregation]) },
{ALT: () => $.SUBRULE($.metricExpression) }
]);
});
$.RULE("aggregationExpression", (outsideAggregation) => { $.RULE("aggregationExpression", (outsideAggregation) => {
const agg = $.CONSUME(Aggregation).image; const agg = $.CONSUME(Aggregation).image;
let value = [aggregationsMap.get(agg)] let value = [aggregationsMap.get(agg)]
...@@ -122,6 +127,13 @@ class ExpressionsParser extends Parser { ...@@ -122,6 +127,13 @@ class ExpressionsParser extends Parser {
return value; return value;
}); });
$.RULE("metricExpression", () => {
let metricName = $.CONSUME(Identifier).image;
$.CONSUME(LParen);
$.CONSUME(RParen);
return ["METRIC", this.getMetricIdForName(metricName)];
});
$.RULE("fieldExpression", () => { $.RULE("fieldExpression", () => {
let fieldName = $.OR([ let fieldName = $.OR([
{ALT: () => JSON.parse($.CONSUME(StringLiteral).image) }, {ALT: () => JSON.parse($.CONSUME(StringLiteral).image) },
...@@ -133,7 +145,7 @@ class ExpressionsParser extends Parser { ...@@ -133,7 +145,7 @@ class ExpressionsParser extends Parser {
$.RULE("atomicExpression", (outsideAggregation) => { $.RULE("atomicExpression", (outsideAggregation) => {
return $.OR([ return $.OR([
// aggregations not allowed inside other aggregations // aggregations not allowed inside other aggregations
{GATE: () => outsideAggregation, ALT: () => $.SUBRULE($.aggregationExpression, [false]) }, {GATE: () => outsideAggregation, ALT: () => $.SUBRULE($.aggregationOrMetricExpression, [false]) },
// fields not allowed outside aggregations // fields not allowed outside aggregations
{GATE: () => !outsideAggregation, ALT: () => $.SUBRULE($.fieldExpression) }, {GATE: () => !outsideAggregation, ALT: () => $.SUBRULE($.fieldExpression) },
{ALT: () => $.SUBRULE($.parenthesisExpression, [outsideAggregation]) }, {ALT: () => $.SUBRULE($.parenthesisExpression, [outsideAggregation]) },
...@@ -155,17 +167,25 @@ class ExpressionsParser extends Parser { ...@@ -155,17 +167,25 @@ class ExpressionsParser extends Parser {
} }
getFieldIdForName(fieldName) { getFieldIdForName(fieldName) {
for (const field of this._fields) { const fields = this._tableMetadata && this._tableMetadata.fields || [];
if (field.display_name.toLowerCase() === fieldName.toLowerCase()) { for (const field of fields) {
return field.id; if (field.display_name.toLowerCase() === fieldName.toLowerCase()) {
} return field.id;
} }
throw new Error("Unknown field \"" + fieldName + "\""); }
throw new Error("Unknown field \"" + fieldName + "\"");
} }
}
// No need for more than one instance. getMetricIdForName(metricName) {
const parserInstance = new ExpressionsParser([]) 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 + "\"");
}
}
function getSubTokenTypes(TokenClass) { function getSubTokenTypes(TokenClass) {
return TokenClass.extendingTokenTypes.map(tokenType => _.findWhere(allTokens, { tokenType })); return TokenClass.extendingTokenTypes.map(tokenType => _.findWhere(allTokens, { tokenType }));
...@@ -176,11 +196,11 @@ function getTokenSource(TokenClass) { ...@@ -176,11 +196,11 @@ function getTokenSource(TokenClass) {
return TokenClass.PATTERN.source.replace(/^\\/, ""); return TokenClass.PATTERN.source.replace(/^\\/, "");
} }
export function compile(source, { startRule, fields } = {}) { export function compile(source, tableMetadata, { startRule } = {}) {
if (!source) { if (!source) {
return []; return [];
} }
const parser = new ExpressionsParser(ExpressionsLexer.tokenize(source).tokens, fields); const parser = new ExpressionsParser(ExpressionsLexer.tokenize(source).tokens, tableMetadata);
const expression = parser[startRule](); const expression = parser[startRule]();
if (parser.errors.length > 0) { if (parser.errors.length > 0) {
throw parser.errors; throw parser.errors;
...@@ -188,7 +208,9 @@ export function compile(source, { startRule, fields } = {}) { ...@@ -188,7 +208,9 @@ export function compile(source, { startRule, fields } = {}) {
return expression; return expression;
} }
export function suggest(source, { startRule, index = source.length, fields } = {}) { // No need for more than one instance.
const parserInstance = new ExpressionsParser([])
export function suggest(source, tableMetadata, { startRule, index = source.length } = {}) {
const partialSource = source.slice(0, index); const partialSource = source.slice(0, index);
const lexResult = ExpressionsLexer.tokenize(partialSource); const lexResult = ExpressionsLexer.tokenize(partialSource);
if (lexResult.errors.length > 0) { if (lexResult.errors.length > 0) {
...@@ -199,8 +221,8 @@ export function suggest(source, { startRule, index = source.length, fields } = { ...@@ -199,8 +221,8 @@ export function suggest(source, { startRule, index = source.length, fields } = {
let partialSuggestionMode = false let partialSuggestionMode = false
let assistanceTokenVector = lexResult.tokens let assistanceTokenVector = lexResult.tokens
// we have requested assistance while inside a Keyword or Identifier // we have requested assistance while inside an Identifier
if ((lastInputToken instanceof Identifier || lastInputToken instanceof Keyword) && if ((lastInputToken instanceof Identifier) &&
/\w/.test(partialSource[partialSource.length - 1])) { /\w/.test(partialSource[partialSource.length - 1])) {
assistanceTokenVector = assistanceTokenVector.slice(0, -1); assistanceTokenVector = assistanceTokenVector.slice(0, -1);
partialSuggestionMode = true partialSuggestionMode = true
...@@ -245,7 +267,7 @@ export function suggest(source, { startRule, index = source.length, fields } = { ...@@ -245,7 +267,7 @@ export function suggest(source, { startRule, index = source.length, fields } = {
}); });
} else if (nextTokenType === Identifier || nextTokenType === StringLiteral) { } else if (nextTokenType === Identifier || nextTokenType === StringLiteral) {
if (!outsideAggregation) { if (!outsideAggregation) {
finalSuggestions.push(...fields.map(field => ({ finalSuggestions.push(...tableMetadata.fields.map(field => ({
type: "fields", type: "fields",
name: field.display_name, name: field.display_name,
text: /^\w+$/.test(field.display_name) ? text: /^\w+$/.test(field.display_name) ?
...@@ -270,11 +292,18 @@ export function suggest(source, { startRule, index = source.length, fields } = { ...@@ -270,11 +292,18 @@ export function suggest(source, { startRule, index = source.length, fields } = {
postfixTrim: /^\w+\(\)?/ postfixTrim: /^\w+\(\)?/
} }
})); }));
finalSuggestions.push(...tableMetadata.metrics.map(metric => ({
type: "metrics",
name: metric.name,
text: normalizeName(metric.name) + "() ",
prefixTrim: /\w+$/,
postfixTrim: /^\w+\(\)?/
})))
} }
} else if (nextTokenType === NumberLiteral) { } else if (nextTokenType === NumberLiteral) {
// skip number literal // skip number literal
} else { } else {
console.warn("non exhaustive match", suggestion) console.warn("non exhaustive match", nextTokenType.name, suggestion)
} }
} }
......
...@@ -91,7 +91,7 @@ export default class AggregationWidget extends Component { ...@@ -91,7 +91,7 @@ export default class AggregationWidget extends Component {
renderCustomAggregation() { renderCustomAggregation() {
const { aggregation, tableMetadata } = this.props; const { aggregation, tableMetadata } = this.props;
return ( return (
<span className="View-section-aggregation QueryOption p1">{aggregation ? format(aggregation, { fields: tableMetadata.fields }) : "Choose an aggregation"}</span> <span className="View-section-aggregation QueryOption p1">{aggregation ? format(aggregation, tableMetadata) : "Choose an aggregation"}</span>
); );
} }
......
...@@ -51,14 +51,13 @@ export default class ExpressionEditorTextfield extends Component { ...@@ -51,14 +51,13 @@ 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 // 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) { if (!this.state || this.props.expression != newProps.expression || this.props.tableMetadata != newProps.tableMetadata) {
let parsedExpression = newProps.expression; let parsedExpression = newProps.expression;
let expressionString = format(newProps.expression, { fields: this.props.tableMetadata.fields }); let expressionString = format(newProps.expression, this.props.tableMetadata);
let expressionErrorMessage = null; let expressionErrorMessage = null;
let suggestions = []; let suggestions = [];
try { try {
if (expressionString) { if (expressionString) {
compile(expressionString, { compile(expressionString, newProps.tableMetadata, {
startRule: newProps.startRule, startRule: newProps.startRule
fields: newProps.tableMetadata.fields
}); });
} }
} catch (e) { } catch (e) {
...@@ -133,12 +132,12 @@ export default class ExpressionEditorTextfield extends Component { ...@@ -133,12 +132,12 @@ export default class ExpressionEditorTextfield extends Component {
event.preventDefault(); event.preventDefault();
} else if (event.keyCode === KEYCODE_UP) { } else if (event.keyCode === KEYCODE_UP) {
this.setState({ this.setState({
highlightedSuggestion: (highlightedSuggestion - 1) % suggestions.length highlightedSuggestion: (highlightedSuggestion + suggestions.length - 1) % suggestions.length
}); });
event.preventDefault(); event.preventDefault();
} else if (event.keyCode === KEYCODE_DOWN) { } else if (event.keyCode === KEYCODE_DOWN) {
this.setState({ this.setState({
highlightedSuggestion: (highlightedSuggestion + 1) % suggestions.length highlightedSuggestion: (highlightedSuggestion + suggestions.length + 1) % suggestions.length
}); });
event.preventDefault(); event.preventDefault();
} }
...@@ -155,8 +154,13 @@ export default class ExpressionEditorTextfield extends Component { ...@@ -155,8 +154,13 @@ export default class ExpressionEditorTextfield extends Component {
this.clearSuggestions(); this.clearSuggestions();
// whenever our input blurs we push the updated expression to our parent if valid // whenever our input blurs we push the updated expression to our parent if valid
if (isExpression(this.state.parsedExpression)) this.props.onChange(this.state.parsedExpression) if (isExpression(this.state.parsedExpression)) {
else if (this.state.expressionErrorMessage) this.props.onError(this.state.expressionErrorMessage); this.props.onChange(this.state.parsedExpression);
} else if (this.state.expressionErrorMessage) {
this.props.onError(this.state.expressionErrorMessage);
} else {
this.props.onError({ message: "Invalid expression" });
}
} }
onInputClick = () => { onInputClick = () => {
...@@ -175,19 +179,17 @@ export default class ExpressionEditorTextfield extends Component { ...@@ -175,19 +179,17 @@ export default class ExpressionEditorTextfield extends Component {
let parsedExpression; let parsedExpression;
try { try {
parsedExpression = compile(expressionString, { parsedExpression = compile(expressionString, this.props.tableMetadata, {
startRule: this.props.startRule, startRule: this.props.startRule
fields: this.props.tableMetadata.fields
}) })
} catch (e) { } catch (e) {
expressionErrorMessage = e; expressionErrorMessage = e;
console.error("expression error:", expressionErrorMessage); console.error("expression error:", expressionErrorMessage);
} }
try { try {
suggestions = suggest(expressionString, { suggestions = suggest(expressionString, this.props.tableMetadata, {
startRule: this.props.startRule, startRule: this.props.startRule,
index: inputElement.selectionStart, index: inputElement.selectionStart
fields: this.props.tableMetadata.fields
}) })
} catch (e) { } catch (e) {
console.error("suggest error:", e); console.error("suggest error:", e);
......
...@@ -32,7 +32,7 @@ export default class Expressions extends Component { ...@@ -32,7 +32,7 @@ export default class Expressions extends Component {
{ sortedNames && sortedNames.map(name => { 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)}> <div key={name} className="pb1 text-brand text-bold cursor-pointer flex flex-row align-center justify-between" onClick={() => onEditExpression(name)}>
<span>{name}</span> <span>{name}</span>
<Tooltip tooltip={format(expressions[name], { fields: this.props.tableMetadata.fields })}> <Tooltip tooltip={format(expressions[name], this.props.tableMetadata)}>
<span className="QuestionTooltipTarget" /> <span className="QuestionTooltipTarget" />
</Tooltip> </Tooltip>
</div> </div>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment