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 { 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