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 branches found
No related tags found
No related merge requests found
frontend/src/metabase/lib/expressions/aggregation.js
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) {
let fieldID = fieldRef[1],
field = _.findWhere(fields, {id: fieldID});
function formatField(fieldRef, { fields }) {
const fieldId = fieldRef[1];
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;
return displayName.indexOf(' ') === -1 ? displayName : ('"' + displayName + '"');
}
function formatNestedExpression(expression, fields, parens = false) {
let formattedExpression = format(expression, { fields });
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 formatNestedExpression(expression, tableMetadata, parens = false) {
let formattedExpression = format(expression, tableMetadata);
if (VALID_OPERATORS.has(expression[0]) && parens) {
return '(' + formattedExpression + ')';
} else {
......@@ -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;
return isField(arg) ? formatField(arg, fields) :
isExpression(arg) ? formatNestedExpression(arg, fields, parens) :
return isField(arg) ? formatField(arg, tableMetadata) :
isExpression(arg) ? formatNestedExpression(arg, tableMetadata, parens) :
typeof arg === 'number' ? arg :
null;
}
/// 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 (!isExpression(expression)) throw 'Invalid expression: ' + expression;
const [op, ...args] = expression;
if (operators.has(op)) {
return args.map(arg => formatArg(arg, fields, true)).join(` ${op} `)
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, fields, false)).join(", ")})`;
return `${aggregations.get(op)}(${args.map(arg => formatArg(arg, tableMetadata, false)).join(", ")})`;
} else {
throw new Error("Unknown clause " + op);
}
......
import _ from "underscore";
import { mbqlEq } from "../query/util";
import { titleize } from "metabase/lib/formatting";
export const VALID_OPERATORS = new Set([
'+',
......@@ -19,14 +21,23 @@ export const VALID_AGGREGATIONS = new Map(Object.entries({
"max": "Max"
}));
// move to query lib
export function isField(arg) {
return arg && arg.constructor === Array && arg.length === 2 && arg[0] === 'field-id' && typeof arg[1] === 'number';
export function normalizeName(name) {
return titleize(name).replace(/\W+/g, "")
}
// move to query lib
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) {
......
const { Lexer, Parser, extendToken, getImage } = require("chevrotain");
const _ = require("underscore");
import { VALID_AGGREGATIONS } from "../expressions";
import { VALID_AGGREGATIONS, normalizeName } from "../expressions";
export const AGGREGATION_ARITY = new Map([
["Count", 0],
......@@ -23,8 +23,9 @@ const Multi = extendToken("Multi", /\*/, MultiplicativeOperator);
const Div = extendToken("Div", /\//, MultiplicativeOperator);
const Aggregation = extendToken("Aggregation", Lexer.NA);
const Keyword = extendToken('Keyword', Lexer.NA);
const aggregationsTokens = Array.from(VALID_AGGREGATIONS).map(([short, expressionName]) =>
extendToken(expressionName, new RegExp(expressionName), Aggregation)
);
const Identifier = extendToken('Identifier', /\w+/);
var NumberLiteral = extendToken("NumberLiteral", /-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/);
......@@ -37,9 +38,6 @@ const RParen = extendToken('RParen', /\)/);
const WhiteSpace = extendToken("WhiteSpace", /\s+/);
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]));
// whitespace is normally very common so it is placed first to speed up the lexer
......@@ -47,19 +45,19 @@ export const allTokens = [
WhiteSpace, LParen, RParen, Comma,
Plus, Minus, Multi, Div,
AdditiveOperator, MultiplicativeOperator,
StringLiteral, NumberLiteral,
Aggregation, ...aggregationsTokens,
Keyword, Identifier
StringLiteral, NumberLiteral,
Identifier
];
const ExpressionsLexer = new Lexer(allTokens);
class ExpressionsParser extends Parser {
constructor(input, fields) {
constructor(input, tableMetadata) {
super(input, allTokens, { recoveryEnabled: false });
this._fields = fields || [];
this._tableMetadata = tableMetadata;
let $ = this;
......@@ -106,6 +104,13 @@ class ExpressionsParser extends Parser {
return value
});
$.RULE("aggregationOrMetricExpression", (outsideAggregation) => {
return $.OR([
{ALT: () => $.SUBRULE($.aggregationExpression, [outsideAggregation]) },
{ALT: () => $.SUBRULE($.metricExpression) }
]);
});
$.RULE("aggregationExpression", (outsideAggregation) => {
const agg = $.CONSUME(Aggregation).image;
let value = [aggregationsMap.get(agg)]
......@@ -122,6 +127,13 @@ class ExpressionsParser extends Parser {
return value;
});
$.RULE("metricExpression", () => {
let metricName = $.CONSUME(Identifier).image;
$.CONSUME(LParen);
$.CONSUME(RParen);
return ["METRIC", this.getMetricIdForName(metricName)];
});
$.RULE("fieldExpression", () => {
let fieldName = $.OR([
{ALT: () => JSON.parse($.CONSUME(StringLiteral).image) },
......@@ -133,7 +145,7 @@ class ExpressionsParser extends Parser {
$.RULE("atomicExpression", (outsideAggregation) => {
return $.OR([
// aggregations not allowed inside other aggregations
{GATE: () => outsideAggregation, ALT: () => $.SUBRULE($.aggregationExpression, [false]) },
{GATE: () => outsideAggregation, ALT: () => $.SUBRULE($.aggregationOrMetricExpression, [false]) },
// fields not allowed outside aggregations
{GATE: () => !outsideAggregation, ALT: () => $.SUBRULE($.fieldExpression) },
{ALT: () => $.SUBRULE($.parenthesisExpression, [outsideAggregation]) },
......@@ -155,17 +167,25 @@ class ExpressionsParser extends Parser {
}
getFieldIdForName(fieldName) {
for (const field of this._fields) {
if (field.display_name.toLowerCase() === fieldName.toLowerCase()) {
return field.id;
}
}
throw new Error("Unknown field \"" + 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 + "\"");
}
}
// No need for more than one instance.
const parserInstance = new ExpressionsParser([])
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 + "\"");
}
}
function getSubTokenTypes(TokenClass) {
return TokenClass.extendingTokenTypes.map(tokenType => _.findWhere(allTokens, { tokenType }));
......@@ -176,11 +196,11 @@ function getTokenSource(TokenClass) {
return TokenClass.PATTERN.source.replace(/^\\/, "");
}
export function compile(source, { startRule, fields } = {}) {
export function compile(source, tableMetadata, { startRule } = {}) {
if (!source) {
return [];
}
const parser = new ExpressionsParser(ExpressionsLexer.tokenize(source).tokens, fields);
const parser = new ExpressionsParser(ExpressionsLexer.tokenize(source).tokens, tableMetadata);
const expression = parser[startRule]();
if (parser.errors.length > 0) {
throw parser.errors;
......@@ -188,7 +208,9 @@ export function compile(source, { startRule, fields } = {}) {
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 lexResult = ExpressionsLexer.tokenize(partialSource);
if (lexResult.errors.length > 0) {
......@@ -199,8 +221,8 @@ export function suggest(source, { startRule, index = source.length, fields } = {
let partialSuggestionMode = false
let assistanceTokenVector = lexResult.tokens
// we have requested assistance while inside a Keyword or Identifier
if ((lastInputToken instanceof Identifier || lastInputToken instanceof Keyword) &&
// we have requested assistance while inside an Identifier
if ((lastInputToken instanceof Identifier) &&
/\w/.test(partialSource[partialSource.length - 1])) {
assistanceTokenVector = assistanceTokenVector.slice(0, -1);
partialSuggestionMode = true
......@@ -245,7 +267,7 @@ export function suggest(source, { startRule, index = source.length, fields } = {
});
} else if (nextTokenType === Identifier || nextTokenType === StringLiteral) {
if (!outsideAggregation) {
finalSuggestions.push(...fields.map(field => ({
finalSuggestions.push(...tableMetadata.fields.map(field => ({
type: "fields",
name: field.display_name,
text: /^\w+$/.test(field.display_name) ?
......@@ -270,11 +292,18 @@ export function suggest(source, { startRule, index = source.length, fields } = {
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) {
// skip number literal
} 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 {
renderCustomAggregation() {
const { aggregation, tableMetadata } = this.props;
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 {
// 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, { fields: this.props.tableMetadata.fields });
let expressionString = format(newProps.expression, this.props.tableMetadata);
let expressionErrorMessage = null;
let suggestions = [];
try {
if (expressionString) {
compile(expressionString, {
startRule: newProps.startRule,
fields: newProps.tableMetadata.fields
compile(expressionString, newProps.tableMetadata, {
startRule: newProps.startRule
});
}
} catch (e) {
......@@ -133,12 +132,12 @@ export default class ExpressionEditorTextfield extends Component {
event.preventDefault();
} else if (event.keyCode === KEYCODE_UP) {
this.setState({
highlightedSuggestion: (highlightedSuggestion - 1) % suggestions.length
highlightedSuggestion: (highlightedSuggestion + suggestions.length - 1) % suggestions.length
});
event.preventDefault();
} else if (event.keyCode === KEYCODE_DOWN) {
this.setState({
highlightedSuggestion: (highlightedSuggestion + 1) % suggestions.length
highlightedSuggestion: (highlightedSuggestion + suggestions.length + 1) % suggestions.length
});
event.preventDefault();
}
......@@ -155,8 +154,13 @@ export default class ExpressionEditorTextfield extends Component {
this.clearSuggestions();
// 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)
else if (this.state.expressionErrorMessage) this.props.onError(this.state.expressionErrorMessage);
if (isExpression(this.state.parsedExpression)) {
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 = () => {
......@@ -175,19 +179,17 @@ export default class ExpressionEditorTextfield extends Component {
let parsedExpression;
try {
parsedExpression = compile(expressionString, {
startRule: this.props.startRule,
fields: this.props.tableMetadata.fields
parsedExpression = compile(expressionString, this.props.tableMetadata, {
startRule: this.props.startRule
})
} catch (e) {
expressionErrorMessage = e;
console.error("expression error:", expressionErrorMessage);
}
try {
suggestions = suggest(expressionString, {
suggestions = suggest(expressionString, this.props.tableMetadata, {
startRule: this.props.startRule,
index: inputElement.selectionStart,
fields: this.props.tableMetadata.fields
index: inputElement.selectionStart
})
} catch (e) {
console.error("suggest error:", e);
......
......@@ -32,7 +32,7 @@ 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], { fields: this.props.tableMetadata.fields })}>
<Tooltip tooltip={format(expressions[name], this.props.tableMetadata)}>
<span className="QuestionTooltipTarget" />
</Tooltip>
</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