From 13fe8df9dcf49372c5b1e69b66abaac044b8bff5 Mon Sep 17 00:00:00 2001 From: Gabe Lyons Date: Fri, 13 Apr 2018 11:20:53 -0700 Subject: [PATCH] [Explore] Adding custom expressions to adhoc metrics (#4736) * adding custom expressions to adhoc metrics * adjusted transitions and made the box expandable --- .../SqlLab/components/AceEditorWrapper.jsx | 5 +- .../assets/javascripts/explore/AdhocMetric.js | 71 +++++++- .../components/AdhocMetricEditPopover.jsx | 164 +++++++++++++++--- .../explore/components/AdhocMetricOption.jsx | 7 + .../components/MetricDefinitionValue.jsx | 1 - .../components/controls/MetricsControl.jsx | 11 +- .../assets/javascripts/explore/constants.js | 3 + superset/assets/javascripts/explore/main.css | 23 +++ .../explore/propTypes/adhocMetricType.js | 19 +- .../javascripts/explore/AdhocMetric_spec.js | 106 ++++++++++- .../AdhocMetricEditPopover_spec.jsx | 27 ++- .../components/MetricsControl_spec.jsx | 7 +- superset/connectors/sqla/models.py | 16 +- superset/utils.py | 23 ++- tests/druid_func_tests.py | 2 + 15 files changed, 430 insertions(+), 55 deletions(-) diff --git a/superset/assets/javascripts/SqlLab/components/AceEditorWrapper.jsx b/superset/assets/javascripts/SqlLab/components/AceEditorWrapper.jsx index 9a9b0379922c3..6aef34cbcff77 100644 --- a/superset/assets/javascripts/SqlLab/components/AceEditorWrapper.jsx +++ b/superset/assets/javascripts/SqlLab/components/AceEditorWrapper.jsx @@ -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 = ( @@ -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', })); diff --git a/superset/assets/javascripts/explore/AdhocMetric.js b/superset/assets/javascripts/explore/AdhocMetric.js index e123521b4c722..5c62f0544f896 100644 --- a/superset/assets/javascripts/explore/AdhocMetric.js +++ b/superset/assets/javascripts/explore/AdhocMetric.js @@ -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(); @@ -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) { @@ -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); + } } diff --git a/superset/assets/javascripts/explore/components/AdhocMetricEditPopover.jsx b/superset/assets/javascripts/explore/components/AdhocMetricEditPopover.jsx index 0964a51c5dd43..4fb8032089fa1 100644 --- a/superset/assets/javascripts/explore/components/AdhocMetricEditPopover.jsx +++ b/superset/assets/javascripts/explore/components/AdhocMetricEditPopover.jsx @@ -1,7 +1,11 @@ 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'; @@ -9,13 +13,17 @@ 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, }; @@ -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', @@ -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() { @@ -48,7 +84,10 @@ 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) { @@ -56,6 +95,16 @@ export default class AdhocMetricEditPopover extends React.Component { 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, }), }); } @@ -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 => ( @@ -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, @@ -101,13 +181,13 @@ export default class AdhocMetricEditPopover extends React.Component { const popoverTitle = ( ); - 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 ( - - column - - - - aggregate - - - - + + + column + + + + aggregate + + + + { + this.props.datasourceType !== 'druid' && + + + + + + } + +
+ + + +
); } diff --git a/superset/assets/javascripts/explore/components/AdhocMetricOption.jsx b/superset/assets/javascripts/explore/components/AdhocMetricOption.jsx index 88dd0d7ee1877..e7b270e806371 100644 --- a/superset/assets/javascripts/explore/components/AdhocMetricOption.jsx +++ b/superset/assets/javascripts/explore/components/AdhocMetricOption.jsx @@ -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() { @@ -28,6 +33,7 @@ export default class AdhocMetricOption extends React.PureComponent { const { adhocMetric } = this.props; const overlay = (