Skip to content

Commit

Permalink
[Explore] Adding custom expressions to adhoc metrics (apache#4736)
Browse files Browse the repository at this point in the history
* adding custom expressions to adhoc metrics

* adjusted transitions and made the box expandable
  • Loading branch information
Gabe Lyons authored and timifasubaa committed May 31, 2018
1 parent 379332f commit 13fe8df
Show file tree
Hide file tree
Showing 15 changed files with 430 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const langTools = ace.acequire('ace/ext/language_tools');
const keywords = (
'SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|AND|OR|GROUP|BY|ORDER|LIMIT|OFFSET|HAVING|AS|CASE|' +
'WHEN|ELSE|END|TYPE|LEFT|RIGHT|JOIN|ON|OUTER|DESC|ASC|UNION|CREATE|TABLE|PRIMARY|KEY|IF|' +
'FOREIGN|NOT|REFERENCES|DEFAULT|NULL|INNER|CROSS|NATURAL|DATABASE|DROP|GRANT'
'FOREIGN|NOT|REFERENCES|DEFAULT|NULL|INNER|CROSS|NATURAL|DATABASE|DROP|GRANT|SUM|MAX|MIN|COUNT|' +
'AVG|DISTINCT'
);

const dataTypes = (
Expand All @@ -21,7 +22,7 @@ const dataTypes = (
);

const sqlKeywords = [].concat(keywords.split('|'), dataTypes.split('|'));
const sqlWords = sqlKeywords.map(s => ({
export const sqlWords = sqlKeywords.map(s => ({
name: s, value: s, score: 60, meta: 'sql',
}));

Expand Down
71 changes: 68 additions & 3 deletions superset/assets/javascripts/explore/AdhocMetric.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
import { sqlaAutoGeneratedMetricRegex } from './constants';

export const EXPRESSION_TYPES = {
SIMPLE: 'SIMPLE',
SQL: 'SQL',
};

function inferSqlExpressionColumn(adhocMetric) {
if (adhocMetric.sqlExpression && sqlaAutoGeneratedMetricRegex.test(adhocMetric.sqlExpression)) {
const indexFirstCloseParen = adhocMetric.sqlExpression.indexOf(')');
const indexPairedOpenParen =
adhocMetric.sqlExpression.substring(0, indexFirstCloseParen).lastIndexOf('(');
if (indexFirstCloseParen > 0 && indexPairedOpenParen > 0) {
return adhocMetric.sqlExpression.substring(indexPairedOpenParen + 1, indexFirstCloseParen);
}
}
return null;
}

function inferSqlExpressionAggregate(adhocMetric) {
if (adhocMetric.sqlExpression && sqlaAutoGeneratedMetricRegex.test(adhocMetric.sqlExpression)) {
const indexFirstOpenParen = adhocMetric.sqlExpression.indexOf('(');
if (indexFirstOpenParen > 0) {
return adhocMetric.sqlExpression.substring(0, indexFirstOpenParen);
}
}
return null;
}

export default class AdhocMetric {
constructor(adhocMetric) {
this.column = adhocMetric.column;
this.aggregate = adhocMetric.aggregate;
this.expressionType = adhocMetric.expressionType || EXPRESSION_TYPES.SIMPLE;
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
// try to be clever in the case of transitioning from Sql expression back to simple expression
const inferredColumn = inferSqlExpressionColumn(adhocMetric);
this.column = adhocMetric.column || (inferredColumn && { column_name: inferredColumn });
this.aggregate = adhocMetric.aggregate || inferSqlExpressionAggregate(adhocMetric);
this.sqlExpression = null;
} else if (this.expressionType === EXPRESSION_TYPES.SQL) {
this.sqlExpression = adhocMetric.sqlExpression;
this.column = null;
this.aggregate = null;
}
this.hasCustomLabel = !!(adhocMetric.hasCustomLabel && adhocMetric.label);
this.fromFormData = !!adhocMetric.optionName;
this.label = this.hasCustomLabel ? adhocMetric.label : this.getDefaultLabel();
Expand All @@ -11,7 +50,14 @@ export default class AdhocMetric {
}

getDefaultLabel() {
return `${this.aggregate || ''}(${(this.column && this.column.column_name) || ''})`;
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
return `${this.aggregate || ''}(${(this.column && this.column.column_name) || ''})`;
} else if (this.expressionType === EXPRESSION_TYPES.SQL) {
return this.sqlExpression.length < 43 ?
this.sqlExpression :
this.sqlExpression.substring(0, 40) + '...';
}
return 'malformatted metric';
}

duplicateWith(nextFields) {
Expand All @@ -23,10 +69,29 @@ export default class AdhocMetric {

equals(adhocMetric) {
return adhocMetric.label === this.label &&
adhocMetric.expressionType === this.expressionType &&
adhocMetric.sqlExpression === this.sqlExpression &&
adhocMetric.aggregate === this.aggregate &&
(
(adhocMetric.column && adhocMetric.column.column_name) ===
(this.column && this.column.column_name)
);
}

isValid() {
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
return !!(this.column && this.aggregate);
} else if (this.expressionType === EXPRESSION_TYPES.SQL) {
return !!(this.sqlExpression);
}
return false;
}

inferSqlExpressionAggregate() {
return inferSqlExpressionAggregate(this);
}

inferSqlExpressionColumn() {
return inferSqlExpressionColumn(this);
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, ControlLabel, FormGroup, Popover } from 'react-bootstrap';
import { Button, ControlLabel, FormGroup, Popover, Tab, Tabs } from 'react-bootstrap';
import VirtualizedSelect from 'react-virtualized-select';
import AceEditor from 'react-ace';
import 'brace/mode/sql';
import 'brace/theme/github';
import 'brace/ext/language_tools';

import { AGGREGATES } from '../constants';
import { t } from '../../locales';
import VirtualizedRendererWrap from '../../components/VirtualizedRendererWrap';
import OnPasteSelect from '../../components/OnPasteSelect';
import AdhocMetricEditPopoverTitle from './AdhocMetricEditPopoverTitle';
import columnType from '../propTypes/columnType';
import AdhocMetric from '../AdhocMetric';
import AdhocMetric, { EXPRESSION_TYPES } from '../AdhocMetric';
import ColumnOption from '../../components/ColumnOption';
import { sqlWords } from '../../SqlLab/components/AceEditorWrapper';

const langTools = ace.acequire('ace/ext/language_tools');

const propTypes = {
adhocMetric: PropTypes.instanceOf(AdhocMetric).isRequired,
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
onResize: PropTypes.func.isRequired,
columns: PropTypes.arrayOf(columnType),
datasourceType: PropTypes.string,
};
Expand All @@ -24,14 +32,25 @@ const defaultProps = {
columns: [],
};

const startingWidth = 300;
const startingHeight = 180;

export default class AdhocMetricEditPopover extends React.Component {
constructor(props) {
super(props);
this.onSave = this.onSave.bind(this);
this.onColumnChange = this.onColumnChange.bind(this);
this.onAggregateChange = this.onAggregateChange.bind(this);
this.onSqlExpressionChange = this.onSqlExpressionChange.bind(this);
this.onLabelChange = this.onLabelChange.bind(this);
this.state = { adhocMetric: this.props.adhocMetric };
this.onDragDown = this.onDragDown.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.state = {
adhocMetric: this.props.adhocMetric,
width: startingWidth,
height: startingHeight,
};
this.selectProps = {
multi: false,
name: 'select-column',
Expand All @@ -40,6 +59,23 @@ export default class AdhocMetricEditPopover extends React.Component {
clearable: true,
selectWrap: VirtualizedSelect,
};
if (langTools) {
const words = sqlWords.concat(this.props.columns.map(column => (
{ name: column.column_name, value: column.column_name, score: 50, meta: 'column' }
)));
const completer = {
getCompletions: (aceEditor, session, pos, prefix, callback) => {
callback(null, words);
},
};
langTools.setCompleters([completer]);
}
document.addEventListener('mouseup', this.onMouseUp);
}

componentWillUnmount() {
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('mousemove', this.onMouseMove);
}

onSave() {
Expand All @@ -48,14 +84,27 @@ export default class AdhocMetricEditPopover extends React.Component {
}

onColumnChange(column) {
this.setState({ adhocMetric: this.state.adhocMetric.duplicateWith({ column }) });
this.setState({ adhocMetric: this.state.adhocMetric.duplicateWith({
column,
expressionType: EXPRESSION_TYPES.SIMPLE,
}) });
}

onAggregateChange(aggregate) {
// we construct this object explicitly to overwrite the value in the case aggregate is null
this.setState({
adhocMetric: this.state.adhocMetric.duplicateWith({
aggregate: aggregate && aggregate.aggregate,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
});
}

onSqlExpressionChange(sqlExpression) {
this.setState({
adhocMetric: this.state.adhocMetric.duplicateWith({
sqlExpression,
expressionType: EXPRESSION_TYPES.SQL,
}),
});
}
Expand All @@ -68,13 +117,44 @@ export default class AdhocMetricEditPopover extends React.Component {
});
}

onDragDown(e) {
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStartWidth = this.state.width;
this.dragStartHeight = this.state.height;
document.addEventListener('mousemove', this.onMouseMove);
}

onMouseMove(e) {
this.props.onResize();
this.setState({
width: Math.max(this.dragStartWidth + (e.clientX - this.dragStartX), startingWidth),
height: Math.max(this.dragStartHeight + (e.clientY - this.dragStartY) * 2, startingHeight),
});
}

onMouseUp() {
document.removeEventListener('mousemove', this.onMouseMove);
}

render() {
const { adhocMetric, columns, onChange, onClose, datasourceType, ...popoverProps } = this.props;
const {
adhocMetric: propsAdhocMetric,
columns,
onChange,
onClose,
onResize,
datasourceType,
...popoverProps
} = this.props;

const { adhocMetric } = this.state;

const columnSelectProps = {
placeholder: t('%s column(s)', columns.length),
options: columns,
value: this.state.adhocMetric.column && this.state.adhocMetric.column.column_name,
value: (adhocMetric.column && adhocMetric.column.column_name) ||
adhocMetric.inferSqlExpressionColumn(),
onChange: this.onColumnChange,
optionRenderer: VirtualizedRendererWrap(option => (
<ColumnOption column={option} showType />
Expand All @@ -86,7 +166,7 @@ export default class AdhocMetricEditPopover extends React.Component {
const aggregateSelectProps = {
placeholder: t('%s aggregates(s)', Object.keys(AGGREGATES).length),
options: Object.keys(AGGREGATES).map(aggregate => ({ aggregate })),
value: this.state.adhocMetric.aggregate,
value: adhocMetric.aggregate || adhocMetric.inferSqlExpressionAggregate(),
onChange: this.onAggregateChange,
optionRenderer: VirtualizedRendererWrap(aggregate => aggregate.aggregate),
valueRenderer: aggregate => aggregate.aggregate,
Expand All @@ -101,38 +181,68 @@ export default class AdhocMetricEditPopover extends React.Component {

const popoverTitle = (
<AdhocMetricEditPopoverTitle
adhocMetric={this.state.adhocMetric}
adhocMetric={adhocMetric}
onChange={this.onLabelChange}
/>
);

const stateIsValid = this.state.adhocMetric.column && this.state.adhocMetric.aggregate;
const hasUnsavedChanges = this.state.adhocMetric.equals(this.props.adhocMetric);
const stateIsValid = adhocMetric.isValid();
const hasUnsavedChanges = !adhocMetric.equals(propsAdhocMetric);

return (
<Popover
id="metrics-edit-popover"
title={popoverTitle}
{...popoverProps}
>
<FormGroup>
<ControlLabel><strong>column</strong></ControlLabel>
<OnPasteSelect {...this.selectProps} {...columnSelectProps} />
</FormGroup>
<FormGroup>
<ControlLabel><strong>aggregate</strong></ControlLabel>
<OnPasteSelect {...this.selectProps} {...aggregateSelectProps} />
</FormGroup>
<Button
disabled={!stateIsValid}
bsStyle={(hasUnsavedChanges || !stateIsValid) ? 'default' : 'primary'}
bsSize="small"
className="m-r-5"
onClick={this.onSave}
<Tabs
id="adhoc-metric-edit-tabs"
defaultActiveKey={adhocMetric.expressionType}
className="adhoc-metric-edit-tabs"
style={{ height: this.state.height, width: this.state.width }}
>
Save
</Button>
<Button bsSize="small" onClick={this.props.onClose}>Close</Button>
<Tab className="adhoc-metric-edit-tab" eventKey={EXPRESSION_TYPES.SIMPLE} title="Simple">
<FormGroup>
<ControlLabel><strong>column</strong></ControlLabel>
<OnPasteSelect {...this.selectProps} {...columnSelectProps} />
</FormGroup>
<FormGroup>
<ControlLabel><strong>aggregate</strong></ControlLabel>
<OnPasteSelect {...this.selectProps} {...aggregateSelectProps} />
</FormGroup>
</Tab>
{
this.props.datasourceType !== 'druid' &&
<Tab className="adhoc-metric-edit-tab" eventKey={EXPRESSION_TYPES.SQL} title="Custom SQL">
<FormGroup>
<AceEditor
mode="sql"
theme="github"
height={(this.state.height - 40) + 'px'}
onChange={this.onSqlExpressionChange}
width="100%"
showGutter={false}
value={adhocMetric.sqlExpression || adhocMetric.getDefaultLabel()}
editorProps={{ $blockScrolling: true }}
enableLiveAutocompletion
/>
</FormGroup>
</Tab>
}
</Tabs>
<div>
<Button
disabled={!stateIsValid}
bsStyle={(hasUnsavedChanges && stateIsValid) ? 'primary' : 'default'}
bsSize="small"
className="m-r-5"
onClick={this.onSave}
>
Save
</Button>
<Button bsSize="small" onClick={this.props.onClose}>Close</Button>
<i onMouseDown={this.onDragDown} className="glyphicon glyphicon-resize-full edit-popover-resize" />
</div>
</Popover>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export default class AdhocMetricOption extends React.PureComponent {
constructor(props) {
super(props);
this.closeMetricEditOverlay = this.closeMetricEditOverlay.bind(this);
this.onPopoverResize = this.onPopoverResize.bind(this);
}

onPopoverResize() {
this.forceUpdate();
}

closeMetricEditOverlay() {
Expand All @@ -28,6 +33,7 @@ export default class AdhocMetricOption extends React.PureComponent {
const { adhocMetric } = this.props;
const overlay = (
<AdhocMetricEditPopover
onResize={this.onPopoverResize}
adhocMetric={adhocMetric}
onChange={this.props.onMetricEdit}
onClose={this.closeMetricEditOverlay}
Expand All @@ -44,6 +50,7 @@ export default class AdhocMetricOption extends React.PureComponent {
disabled
overlay={overlay}
rootClose
shouldUpdatePosition
defaultOverlayShown={!adhocMetric.fromFormData}
>
<Label style={{ margin: this.props.multi ? 0 : 3, cursor: 'pointer' }}>
Expand Down
Loading

0 comments on commit 13fe8df

Please sign in to comment.