From 1ac2d39d9483b2ba5f13f3c767015bc192407cad Mon Sep 17 00:00:00 2001 From: GabeLoins Date: Thu, 29 Mar 2018 12:04:35 -0700 Subject: [PATCH 1/2] adding custom expressions to adhoc metrics --- .../SqlLab/components/AceEditorWrapper.jsx | 5 +- .../assets/javascripts/explore/AdhocMetric.js | 68 ++++++++++- .../components/AdhocMetricEditPopover.jsx | 115 ++++++++++++++---- .../components/MetricDefinitionValue.jsx | 1 - .../components/controls/MetricsControl.jsx | 2 +- superset/assets/javascripts/explore/main.css | 16 +++ .../explore/propTypes/adhocMetricType.js | 19 ++- .../javascripts/explore/AdhocMetric_spec.js | 106 +++++++++++++++- .../AdhocMetricEditPopover_spec.jsx | 16 ++- .../components/MetricsControl_spec.jsx | 7 +- superset/connectors/sqla/models.py | 16 ++- superset/utils.py | 23 +++- tests/druid_func_tests.py | 2 + 13 files changed, 345 insertions(+), 51 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..6ed4f562242d7 100644 --- a/superset/assets/javascripts/explore/AdhocMetric.js +++ b/superset/assets/javascripts/explore/AdhocMetric.js @@ -1,7 +1,43 @@ +export const EXPRESSION_TYPES = { + SIMPLE: 'SIMPLE', + SQL: 'SQL', +}; + +function inferSqlExpressionColumn(adhocMetric) { + if (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) { + 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) { + 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 +47,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 +66,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..cebe85709108b 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,8 +13,11 @@ 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, @@ -30,16 +37,26 @@ export default class AdhocMetricEditPopover extends React.Component { 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.selectProps = { - multi: false, + this.state = { adhocMetric: this.props.adhocMetric }; this.selectProps = { multi: false, name: 'select-column', labelKey: 'label', autosize: false, 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]); + } } onSave() { @@ -48,16 +65,25 @@ 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, - }), - }); + 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, + }) }); } onLabelChange(e) { @@ -69,12 +95,22 @@ export default class AdhocMetricEditPopover extends React.Component { } render() { - const { adhocMetric, columns, onChange, onClose, datasourceType, ...popoverProps } = this.props; + const { + adhocMetric: propsAdhocMetric, + columns, + onChange, + onClose, + 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 +122,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 +137,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' && + + + + + + } + - +
+ + + +
); } -} AdhocMetricEditPopover.propTypes = propTypes; +} +AdhocMetricEditPopover.propTypes = propTypes; AdhocMetricEditPopover.defaultProps = defaultProps; 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 = (