mistercrunch closed pull request #4663: [Explore] Streamlined metric 
definitions for SQLA and Druid
URL: https://github.com/apache/incubator-superset/pull/4663
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

diff --git a/superset/assets/.eslintrc b/superset/assets/.eslintrc
index c8b076604c..921e927b82 100644
--- a/superset/assets/.eslintrc
+++ b/superset/assets/.eslintrc
@@ -38,5 +38,8 @@
     "react/no-unescaped-entities": 0,
     "react/no-unused-prop-types": 0,
     "react/no-string-refs": 0,
+    "indent": 0,
+    "no-multi-spaces": 0,
+    "padded-blocks": 0,
   }
 }
diff --git a/superset/assets/javascripts/components/ColumnTypeLabel.jsx 
b/superset/assets/javascripts/components/ColumnTypeLabel.jsx
index 719891e4a2..33f8319120 100644
--- a/superset/assets/javascripts/components/ColumnTypeLabel.jsx
+++ b/superset/assets/javascripts/components/ColumnTypeLabel.jsx
@@ -2,16 +2,20 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 const propTypes = {
-  type: PropTypes.string.isRequired,
+  type: PropTypes.string,
 };
 
 export default function ColumnTypeLabel({ type }) {
   let stringIcon = '';
-  if (type === '' || type === 'expression') {
+  if (typeof type !== 'string') {
+    stringIcon = '?';
+  } else if (type === '' || type === 'expression') {
     stringIcon = 'ƒ';
+  } else if (type === 'aggregate') {
+    stringIcon = 'AGG';
   } else if (type.match(/.*char.*/i) || type.match(/string.*/i) || 
type.match(/.*text.*/i)) {
     stringIcon = 'ABC';
-  } else if (type.match(/.*int.*/i) || type === 'LONG' || type === 'DOUBLE') {
+  } else if (type.match(/.*int.*/i) || type === 'LONG' || type === 'DOUBLE' || 
type === 'FLOAT') {
     stringIcon = '#';
   } else if (type.match(/.*bool.*/i)) {
     stringIcon = 'T/F';
diff --git a/superset/assets/javascripts/components/OnPasteSelect.jsx 
b/superset/assets/javascripts/components/OnPasteSelect.jsx
index b043bf317d..40bbbd0932 100644
--- a/superset/assets/javascripts/components/OnPasteSelect.jsx
+++ b/superset/assets/javascripts/components/OnPasteSelect.jsx
@@ -49,8 +49,8 @@ export default class OnPasteSelect extends React.Component {
   render() {
     const SelectComponent = this.props.selectWrap;
     const refFunc = (ref) => {
-      if (this.props.ref) {
-        this.props.ref(ref);
+      if (this.props.refFunc) {
+        this.props.refFunc(ref);
       }
       this.pasteInput = ref;
     };
@@ -68,7 +68,7 @@ export default class OnPasteSelect extends React.Component {
 OnPasteSelect.propTypes = {
   separator: PropTypes.string.isRequired,
   selectWrap: PropTypes.func.isRequired,
-  ref: PropTypes.func,
+  refFunc: PropTypes.func,
   onChange: PropTypes.func.isRequired,
   valueKey: PropTypes.string.isRequired,
   labelKey: PropTypes.string.isRequired,
diff --git a/superset/assets/javascripts/dashboard/reducers.js 
b/superset/assets/javascripts/dashboard/reducers.js
index bf42532edb..1cc3e76cd8 100644
--- a/superset/assets/javascripts/dashboard/reducers.js
+++ b/superset/assets/javascripts/dashboard/reducers.js
@@ -1,3 +1,4 @@
+/* eslint-disable camelcase */
 import { combineReducers } from 'redux';
 import d3 from 'd3';
 import shortid from 'shortid';
diff --git a/superset/assets/javascripts/explore/AdhocMetric.js 
b/superset/assets/javascripts/explore/AdhocMetric.js
new file mode 100644
index 0000000000..e123521b4c
--- /dev/null
+++ b/superset/assets/javascripts/explore/AdhocMetric.js
@@ -0,0 +1,32 @@
+export default class AdhocMetric {
+  constructor(adhocMetric) {
+    this.column = adhocMetric.column;
+    this.aggregate = adhocMetric.aggregate;
+    this.hasCustomLabel = !!(adhocMetric.hasCustomLabel && adhocMetric.label);
+    this.fromFormData = !!adhocMetric.optionName;
+    this.label = this.hasCustomLabel ? adhocMetric.label : 
this.getDefaultLabel();
+
+    this.optionName = adhocMetric.optionName ||
+      `metric_${Math.random().toString(36).substring(2, 
15)}_${Math.random().toString(36).substring(2, 15)}`;
+  }
+
+  getDefaultLabel() {
+    return `${this.aggregate || ''}(${(this.column && this.column.column_name) 
|| ''})`;
+  }
+
+  duplicateWith(nextFields) {
+    return new AdhocMetric({
+      ...this,
+      ...nextFields,
+    });
+  }
+
+  equals(adhocMetric) {
+    return adhocMetric.label === this.label &&
+      adhocMetric.aggregate === this.aggregate &&
+      (
+        (adhocMetric.column && adhocMetric.column.column_name) ===
+        (this.column && this.column.column_name)
+      );
+  }
+}
diff --git 
a/superset/assets/javascripts/explore/components/AdhocMetricEditPopover.jsx 
b/superset/assets/javascripts/explore/components/AdhocMetricEditPopover.jsx
new file mode 100644
index 0000000000..0964a51c5d
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/AdhocMetricEditPopover.jsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, ControlLabel, FormGroup, Popover } from 'react-bootstrap';
+import VirtualizedSelect from 'react-virtualized-select';
+
+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 ColumnOption from '../../components/ColumnOption';
+
+const propTypes = {
+  adhocMetric: PropTypes.instanceOf(AdhocMetric).isRequired,
+  onChange: PropTypes.func.isRequired,
+  onClose: PropTypes.func.isRequired,
+  columns: PropTypes.arrayOf(columnType),
+  datasourceType: PropTypes.string,
+};
+
+const defaultProps = {
+  columns: [],
+};
+
+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.onLabelChange = this.onLabelChange.bind(this);
+    this.state = { adhocMetric: this.props.adhocMetric };
+    this.selectProps = {
+      multi: false,
+      name: 'select-column',
+      labelKey: 'label',
+      autosize: false,
+      clearable: true,
+      selectWrap: VirtualizedSelect,
+    };
+  }
+
+  onSave() {
+    this.props.onChange(this.state.adhocMetric);
+    this.props.onClose();
+  }
+
+  onColumnChange(column) {
+    this.setState({ adhocMetric: this.state.adhocMetric.duplicateWith({ column 
}) });
+  }
+
+  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,
+      }),
+    });
+  }
+
+  onLabelChange(e) {
+    this.setState({
+      adhocMetric: this.state.adhocMetric.duplicateWith({
+        label: e.target.value, hasCustomLabel: true,
+      }),
+    });
+  }
+
+  render() {
+    const { adhocMetric, columns, onChange, onClose, datasourceType, 
...popoverProps } = this.props;
+
+    const columnSelectProps = {
+      placeholder: t('%s column(s)', columns.length),
+      options: columns,
+      value: this.state.adhocMetric.column && 
this.state.adhocMetric.column.column_name,
+      onChange: this.onColumnChange,
+      optionRenderer: VirtualizedRendererWrap(option => (
+        <ColumnOption column={option} showType />
+      )),
+      valueRenderer: column => column.column_name,
+      valueKey: 'column_name',
+    };
+
+    const aggregateSelectProps = {
+      placeholder: t('%s aggregates(s)', Object.keys(AGGREGATES).length),
+      options: Object.keys(AGGREGATES).map(aggregate => ({ aggregate })),
+      value: this.state.adhocMetric.aggregate,
+      onChange: this.onAggregateChange,
+      optionRenderer: VirtualizedRendererWrap(aggregate => 
aggregate.aggregate),
+      valueRenderer: aggregate => aggregate.aggregate,
+      valueKey: 'aggregate',
+    };
+
+    if (this.props.datasourceType === 'druid') {
+      aggregateSelectProps.options = aggregateSelectProps.options.filter((
+        option => option.aggregate !== 'AVG'
+      ));
+    }
+
+    const popoverTitle = (
+      <AdhocMetricEditPopoverTitle
+        adhocMetric={this.state.adhocMetric}
+        onChange={this.onLabelChange}
+      />
+    );
+
+    const stateIsValid = this.state.adhocMetric.column && 
this.state.adhocMetric.aggregate;
+    const hasUnsavedChanges = 
this.state.adhocMetric.equals(this.props.adhocMetric);
+
+    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}
+        >
+          Save
+        </Button>
+        <Button bsSize="small" onClick={this.props.onClose}>Close</Button>
+      </Popover>
+    );
+  }
+}
+AdhocMetricEditPopover.propTypes = propTypes;
+AdhocMetricEditPopover.defaultProps = defaultProps;
diff --git 
a/superset/assets/javascripts/explore/components/AdhocMetricEditPopoverTitle.jsx
 
b/superset/assets/javascripts/explore/components/AdhocMetricEditPopoverTitle.jsx
new file mode 100644
index 0000000000..d14b111189
--- /dev/null
+++ 
b/superset/assets/javascripts/explore/components/AdhocMetricEditPopoverTitle.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormControl, OverlayTrigger, Tooltip } from 'react-bootstrap';
+import AdhocMetric from '../AdhocMetric';
+
+const propTypes = {
+  adhocMetric: PropTypes.instanceOf(AdhocMetric),
+  onChange: PropTypes.func.isRequired,
+};
+
+export default class AdhocMetricEditPopoverTitle extends React.Component {
+  constructor(props) {
+    super(props);
+    this.onMouseOver = this.onMouseOver.bind(this);
+    this.onMouseOut = this.onMouseOut.bind(this);
+    this.onClick = this.onClick.bind(this);
+    this.onBlur = this.onBlur.bind(this);
+    this.state = {
+      isHovered: false,
+      isEditable: false,
+    };
+  }
+
+  onMouseOver() {
+    this.setState({ isHovered: true });
+  }
+
+  onMouseOut() {
+    this.setState({ isHovered: false });
+  }
+
+  onClick() {
+    this.setState({ isEditable: true });
+  }
+
+  onBlur() {
+    this.setState({ isEditable: false });
+  }
+
+  refFunc(ref) {
+    if (ref) {
+      ref.focus();
+    }
+  }
+
+  render() {
+    const { adhocMetric, onChange } = this.props;
+
+    const editPrompt = <Tooltip id="edit-metric-label-tooltip">Click to edit 
label</Tooltip>;
+
+    return (
+      <OverlayTrigger
+        placement="top"
+        overlay={editPrompt}
+        onMouseOver={this.onMouseOver}
+        onMouseOut={this.onMouseOut}
+        onClick={this.onClick}
+        onBlur={this.onBlur}
+      >
+        {this.state.isEditable ?
+          <FormControl
+            className="metric-edit-popover-label-input"
+            type="text"
+            placeholder={adhocMetric.label}
+            value={adhocMetric.hasCustomLabel ? adhocMetric.label : ''}
+            onChange={onChange}
+            inputRef={this.refFunc}
+          /> :
+          <span>
+            {adhocMetric.hasCustomLabel ? adhocMetric.label : 'My Metric'}
+            &nbsp;
+            <i className="fa fa-pencil" style={{ color: this.state.isHovered ? 
'black' : 'grey' }} />
+          </span>
+        }
+      </OverlayTrigger>
+    );
+  }
+}
+AdhocMetricEditPopoverTitle.propTypes = propTypes;
diff --git 
a/superset/assets/javascripts/explore/components/AdhocMetricOption.jsx 
b/superset/assets/javascripts/explore/components/AdhocMetricOption.jsx
new file mode 100644
index 0000000000..88dd0d7ee1
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/AdhocMetricOption.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Label, OverlayTrigger } from 'react-bootstrap';
+
+import AdhocMetricEditPopover from './AdhocMetricEditPopover';
+import AdhocMetric from '../AdhocMetric';
+import columnType from '../propTypes/columnType';
+
+const propTypes = {
+  adhocMetric: PropTypes.instanceOf(AdhocMetric),
+  onMetricEdit: PropTypes.func.isRequired,
+  columns: PropTypes.arrayOf(columnType),
+  multi: PropTypes.bool,
+  datasourceType: PropTypes.string,
+};
+
+export default class AdhocMetricOption extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.closeMetricEditOverlay = this.closeMetricEditOverlay.bind(this);
+  }
+
+  closeMetricEditOverlay() {
+    this.refs.overlay.hide();
+  }
+
+  render() {
+    const { adhocMetric } = this.props;
+    const overlay = (
+      <AdhocMetricEditPopover
+        adhocMetric={adhocMetric}
+        onChange={this.props.onMetricEdit}
+        onClose={this.closeMetricEditOverlay}
+        columns={this.props.columns}
+        datasourceType={this.props.datasourceType}
+      />
+    );
+
+    return (
+      <OverlayTrigger
+        ref="overlay"
+        placement="right"
+        trigger="click"
+        disabled
+        overlay={overlay}
+        rootClose
+        defaultOverlayShown={!adhocMetric.fromFormData}
+      >
+        <Label style={{ margin: this.props.multi ? 0 : 3, cursor: 'pointer' }}>
+          <div onMouseDownCapture={(e) => { e.stopPropagation(); }}>
+            <span className="m-r-5 option-label">
+              {adhocMetric.label}
+            </span>
+          </div>
+        </Label>
+      </OverlayTrigger>
+    );
+  }
+}
+AdhocMetricOption.propTypes = propTypes;
diff --git a/superset/assets/javascripts/explore/components/AggregateOption.jsx 
b/superset/assets/javascripts/explore/components/AggregateOption.jsx
new file mode 100644
index 0000000000..e56ccf9dbf
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/AggregateOption.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import ColumnTypeLabel from '../../components/ColumnTypeLabel';
+import aggregateOptionType from '../propTypes/aggregateOptionType';
+
+const propTypes = {
+  aggregate: aggregateOptionType,
+  showType: PropTypes.bool,
+};
+
+export default function AggregateOption({ aggregate, showType }) {
+  return (
+    <div>
+      {showType && <ColumnTypeLabel type="aggregate" />}
+      <span className="m-r-5 option-label">
+        {aggregate.aggregate_name}
+      </span>
+    </div>
+  );
+}
+AggregateOption.propTypes = propTypes;
diff --git 
a/superset/assets/javascripts/explore/components/MetricDefinitionOption.jsx 
b/superset/assets/javascripts/explore/components/MetricDefinitionOption.jsx
new file mode 100644
index 0000000000..c275b9c4d5
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/MetricDefinitionOption.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import MetricOption from '../../components/MetricOption';
+import ColumnOption from '../../components/ColumnOption';
+import AggregateOption from './AggregateOption';
+import columnType from '../propTypes/columnType';
+import savedMetricType from '../propTypes/savedMetricType';
+import aggregateOptionType from '../propTypes/aggregateOptionType';
+
+const propTypes = {
+  option: PropTypes.oneOfType([
+    columnType,
+    savedMetricType,
+    aggregateOptionType,
+  ]).isRequired,
+};
+
+export default function MetricDefinitionOption({ option }) {
+  if (option.metric_name) {
+    return (
+      <MetricOption metric={option} showType />
+    );
+  } else if (option.column_name) {
+    return (
+      <ColumnOption column={option} showType />
+    );
+  } else if (option.aggregate_name) {
+    return (
+      <AggregateOption aggregate={option} showType />
+    );
+  }
+  notify.error('You must supply either a saved metric, column or aggregate to 
MetricDefinitionOption');
+  return null;
+}
+MetricDefinitionOption.propTypes = propTypes;
diff --git 
a/superset/assets/javascripts/explore/components/MetricDefinitionValue.jsx 
b/superset/assets/javascripts/explore/components/MetricDefinitionValue.jsx
new file mode 100644
index 0000000000..4997dcee3b
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/MetricDefinitionValue.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import AdhocMetricOption from './AdhocMetricOption';
+import AdhocMetric from '../AdhocMetric';
+import columnType from '../propTypes/columnType';
+import MetricOption from '../../components/MetricOption';
+import savedMetricType from '../propTypes/savedMetricType';
+import adhocMetricType from '../propTypes/adhocMetricType';
+
+const propTypes = {
+  option: PropTypes.oneOfType([
+    savedMetricType,
+    adhocMetricType,
+  ]).isRequired,
+  onMetricEdit: PropTypes.func,
+  columns: PropTypes.arrayOf(columnType),
+  multi: PropTypes.bool,
+  datasourceType: PropTypes.string,
+};
+
+export default function MetricDefinitionValue({
+  option,
+  onMetricEdit,
+  columns,
+  multi,
+  datasourceType,
+}) {
+  if (option.metric_name) {
+    return (
+      <MetricOption metric={option} />
+    );
+  } else if (option instanceof AdhocMetric) {
+    return (
+      <AdhocMetricOption
+        adhocMetric={option}
+        onMetricEdit={onMetricEdit}
+        columns={columns}
+        multi={multi}
+        datasourceType={datasourceType}
+      />
+    );
+  }
+  notify.error('You must supply either a saved metric or adhoc metric to 
MetricDefinitionValue');
+  return null;
+}
+MetricDefinitionValue.propTypes = propTypes;
diff --git 
a/superset/assets/javascripts/explore/components/controls/MetricsControl.jsx 
b/superset/assets/javascripts/explore/components/controls/MetricsControl.jsx
new file mode 100644
index 0000000000..9a16f525a0
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/controls/MetricsControl.jsx
@@ -0,0 +1,256 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import VirtualizedSelect from 'react-virtualized-select';
+import ControlHeader from '../ControlHeader';
+import { t } from '../../../locales';
+import VirtualizedRendererWrap from 
'../../../components/VirtualizedRendererWrap';
+import OnPasteSelect from '../../../components/OnPasteSelect';
+import MetricDefinitionOption from '../MetricDefinitionOption';
+import MetricDefinitionValue from '../MetricDefinitionValue';
+import AdhocMetric from '../../AdhocMetric';
+import columnType from '../../propTypes/columnType';
+import savedMetricType from '../../propTypes/savedMetricType';
+import adhocMetricType from '../../propTypes/adhocMetricType';
+import { AGGREGATES } from '../../constants';
+
+const propTypes = {
+  name: PropTypes.string.isRequired,
+  onChange: PropTypes.func,
+  value: PropTypes.oneOfType([
+    PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, 
adhocMetricType])),
+    PropTypes.oneOfType([PropTypes.string, adhocMetricType]),
+  ]),
+  columns: PropTypes.arrayOf(columnType),
+  savedMetrics: PropTypes.arrayOf(savedMetricType),
+  multi: PropTypes.bool,
+  datasourceType: PropTypes.string,
+};
+
+const defaultProps = {
+  onChange: () => {},
+};
+
+function isDictionaryForAdhocMetric(value) {
+  return value && !(value instanceof AdhocMetric) && value.column && 
value.aggregate && value.label;
+}
+
+// adhoc metrics are stored as dictionaries in URL params. We convert them 
back into the
+// AdhocMetric class for typechecking, consistency and instance method access.
+function coerceAdhocMetrics(value) {
+  if (!value) {
+    return [];
+  }
+  if (!Array.isArray(value)) {
+    if (isDictionaryForAdhocMetric(value)) {
+      return [new AdhocMetric(value)];
+    }
+    return [value];
+  }
+  return value.map((val) => {
+    if (isDictionaryForAdhocMetric(val)) {
+      return new AdhocMetric(val);
+    }
+    return val;
+  });
+}
+
+function getDefaultAggregateForColumn(column) {
+  const type = column.type;
+  if (typeof type !== 'string') {
+    return AGGREGATES.COUNT;
+  } else if (type === '' || type === 'expression') {
+    return AGGREGATES.SUM;
+  } else if (type.match(/.*char.*/i) || type.match(/string.*/i) || 
type.match(/.*text.*/i)) {
+    return AGGREGATES.COUNT_DISTINCT;
+  } else if (type.match(/.*int.*/i) || type === 'LONG' || type === 'DOUBLE' || 
type === 'FLOAT') {
+    return AGGREGATES.SUM;
+  } else if (type.match(/.*bool.*/i)) {
+    return AGGREGATES.MAX;
+  } else if (type.match(/.*time.*/i)) {
+    return AGGREGATES.COUNT;
+  } else if (type.match(/unknown/i)) {
+    return AGGREGATES.COUNT;
+  }
+  return null;
+}
+
+const autoGeneratedMetricRegex = 
/^(LONG|DOUBLE|FLOAT)?(SUM|AVG|MAX|MIN|COUNT)\([A-Z_][A-Z0-9_]*\)$/i;
+function isAutoGeneratedMetric(savedMetric) {
+  return (
+    autoGeneratedMetricRegex.test(savedMetric.expression) ||
+    autoGeneratedMetricRegex.test(savedMetric.verbose_name)
+  );
+}
+
+export default class MetricsControl extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onChange = this.onChange.bind(this);
+    this.onMetricEdit = this.onMetricEdit.bind(this);
+    this.checkIfAggregateInInput = this.checkIfAggregateInInput.bind(this);
+    this.optionsForSelect = this.optionsForSelect.bind(this);
+    this.selectFilterOption = this.selectFilterOption.bind(this);
+    this.optionRenderer = VirtualizedRendererWrap(option => (
+      <MetricDefinitionOption option={option} />
+    ), { ignoreAutogeneratedMetrics: true });
+    this.valueRenderer = option => (
+      <MetricDefinitionValue
+        option={option}
+        onMetricEdit={this.onMetricEdit}
+        columns={this.props.columns}
+        multi={this.props.multi}
+        datasourceType={this.props.datasourceType}
+      />
+    );
+    this.refFunc = (ref) => {
+      if (ref) {
+        // eslint-disable-next-line no-underscore-dangle
+        this.select = ref._selectRef;
+      }
+    };
+    this.state = {
+      aggregateInInput: null,
+      options: this.optionsForSelect(this.props),
+      value: coerceAdhocMetrics(this.props.value),
+    };
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (
+      this.props.columns !== nextProps.columns ||
+      this.props.savedMetrics !== nextProps.savedMetrics
+    ) {
+      this.setState({ options: this.optionsForSelect(nextProps) });
+      this.props.onChange([]);
+    }
+    if (this.props.value !== nextProps.value) {
+      this.setState({ value: coerceAdhocMetrics(nextProps.value) });
+    }
+  }
+
+  onMetricEdit(changedMetric) {
+    let newValue = this.state.value.map((value) => {
+      if (value.optionName === changedMetric.optionName) {
+        return changedMetric;
+      }
+      return value;
+    });
+    if (!this.props.multi) {
+      newValue = newValue[0];
+    }
+    this.props.onChange(newValue);
+  }
+
+  onChange(opts) {
+    let transformedOpts = opts;
+    if (!this.props.multi) {
+      transformedOpts = [opts].filter(option => option);
+    }
+    let optionValues = transformedOpts.map((option) => {
+      if (option.metric_name) {
+        return option.metric_name;
+      } else if (option.column_name) {
+        const clearedAggregate = this.clearedAggregateInInput;
+        this.clearedAggregateInInput = null;
+        return new AdhocMetric({
+          column: option,
+          aggregate: clearedAggregate || getDefaultAggregateForColumn(option),
+        });
+      } else if (option instanceof AdhocMetric) {
+        return option;
+      } else if (option.aggregate_name) {
+        const newValue = `${option.aggregate_name}()`;
+        this.select.setInputValue(newValue);
+        this.select.handleInputChange({ target: { value: newValue } });
+        // we need to set a timeout here or the selectionWill be overwritten
+        // by some browsers (e.g. Chrome)
+        setTimeout(() => {
+          this.select.input.input.selectionStart = newValue.length - 1;
+          this.select.input.input.selectionEnd = newValue.length - 1;
+        }, 0);
+        return null;
+      }
+      return null;
+    }).filter(option => option);
+    if (!this.props.multi) {
+      optionValues = optionValues[0];
+    }
+    this.props.onChange(optionValues);
+  }
+
+  checkIfAggregateInInput(input) {
+    let nextState = { aggregateInInput: null };
+    Object.keys(AGGREGATES).forEach((aggregate) => {
+      if (input.toLowerCase().startsWith(aggregate.toLowerCase() + '(')) {
+        nextState = { aggregateInInput: aggregate };
+      }
+    });
+    this.clearedAggregateInInput = this.state.aggregateInInput;
+    this.setState(nextState);
+  }
+
+  optionsForSelect(props) {
+    const options = [
+      ...props.columns,
+      ...Object.keys(AGGREGATES).map(aggregate => ({ aggregate_name: aggregate 
})),
+      ...props.savedMetrics,
+    ];
+
+    return options.map((option) => {
+      if (option.metric_name) {
+        return { ...option, optionName: option.metric_name };
+      } else if (option.column_name) {
+        return { ...option, optionName: '_col_' + option.column_name };
+      } else if (option.aggregate_name) {
+        return { ...option, optionName: '_aggregate_' + option.aggregate_name 
};
+      }
+      notify.error(`provided invalid option to MetricsControl, ${option}`);
+      return null;
+    });
+  }
+
+  selectFilterOption(option, filterValue) {
+    if (this.state.aggregateInInput) {
+      let endIndex = filterValue.length;
+      if (filterValue.endsWith(')')) {
+        endIndex = filterValue.length - 1;
+      }
+      const valueAfterAggregate = 
filterValue.substring(filterValue.indexOf('(') + 1, endIndex);
+      return option.column_name &&
+        
(option.column_name.toLowerCase().indexOf(valueAfterAggregate.toLowerCase()) >= 
0);
+    }
+    return option.optionName &&
+      (!option.metric_name || !isAutoGeneratedMetric(option)) &&
+      (option.optionName.toLowerCase().indexOf(filterValue.toLowerCase()) >= 
0);
+  }
+
+  render() {
+    // TODO figure out why the dropdown isnt appearing as soon as a metric is 
selected
+    return (
+      <div className="metrics-select">
+        <ControlHeader {...this.props} />
+        <OnPasteSelect
+          multi={this.props.multi}
+          name={`select-${this.props.name}`}
+          placeholder={t('choose a column or aggregate function')}
+          options={this.state.options}
+          value={this.props.multi ? this.state.value : this.state.value[0]}
+          labelKey="label"
+          valueKey="optionName"
+          clearable
+          closeOnSelect
+          onChange={this.onChange}
+          optionRenderer={this.optionRenderer}
+          valueRenderer={this.valueRenderer}
+          onInputChange={this.checkIfAggregateInInput}
+          filterOption={this.selectFilterOption}
+          refFunc={this.refFunc}
+          selectWrap={VirtualizedSelect}
+        />
+      </div>
+    );
+  }
+}
+
+MetricsControl.propTypes = propTypes;
+MetricsControl.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/explore/components/controls/index.js 
b/superset/assets/javascripts/explore/components/controls/index.js
index 35aaeeff2b..a7ca463605 100644
--- a/superset/assets/javascripts/explore/components/controls/index.js
+++ b/superset/assets/javascripts/explore/components/controls/index.js
@@ -17,6 +17,7 @@ import TextControl from './TextControl';
 import TimeSeriesColumnControl from './TimeSeriesColumnControl';
 import ViewportControl from './ViewportControl';
 import VizTypeControl from './VizTypeControl';
+import MetricsControl from './MetricsControl';
 
 const controlMap = {
   AnnotationLayerControl,
@@ -38,5 +39,6 @@ const controlMap = {
   TimeSeriesColumnControl,
   ViewportControl,
   VizTypeControl,
+  MetricsControl,
 };
 export default controlMap;
diff --git a/superset/assets/javascripts/explore/constants.js 
b/superset/assets/javascripts/explore/constants.js
new file mode 100644
index 0000000000..330841f4ac
--- /dev/null
+++ b/superset/assets/javascripts/explore/constants.js
@@ -0,0 +1,9 @@
+export const AGGREGATES = {
+  AVG: 'AVG',
+  COUNT: 'COUNT ',
+  COUNT_DISTINCT: 'COUNT_DISTINCT',
+  MAX: 'MAX',
+  MIN: 'MIN',
+  SUM: 'SUM',
+};
+
diff --git a/superset/assets/javascripts/explore/main.css 
b/superset/assets/javascripts/explore/main.css
index 2add0abf68..c6fede9074 100644
--- a/superset/assets/javascripts/explore/main.css
+++ b/superset/assets/javascripts/explore/main.css
@@ -39,6 +39,10 @@
   width: 100px;
 }
 
+.control-panel-section .metrics-select .Select-multi-value-wrapper 
.Select-input > input {
+  width: 300px;
+}
+
 .background-transparent {
   background-color: transparent !important;
 }
diff --git a/superset/assets/javascripts/explore/propTypes/adhocMetricType.js 
b/superset/assets/javascripts/explore/propTypes/adhocMetricType.js
new file mode 100644
index 0000000000..b11126e8fe
--- /dev/null
+++ b/superset/assets/javascripts/explore/propTypes/adhocMetricType.js
@@ -0,0 +1,10 @@
+import PropTypes from 'prop-types';
+
+import { AGGREGATES } from '../constants';
+import columnType from './columnType';
+
+export default PropTypes.shape({
+  column: columnType.isRequired,
+  aggregate: PropTypes.oneOf(Object.keys(AGGREGATES)).isRequired,
+  label: PropTypes.string.isRequired,
+});
diff --git 
a/superset/assets/javascripts/explore/propTypes/aggregateOptionType.js 
b/superset/assets/javascripts/explore/propTypes/aggregateOptionType.js
new file mode 100644
index 0000000000..0a5eebfa32
--- /dev/null
+++ b/superset/assets/javascripts/explore/propTypes/aggregateOptionType.js
@@ -0,0 +1,5 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+  aggregate_name: PropTypes.string.isRequired,
+});
diff --git a/superset/assets/javascripts/explore/propTypes/columnType.js 
b/superset/assets/javascripts/explore/propTypes/columnType.js
new file mode 100644
index 0000000000..5ff33e590a
--- /dev/null
+++ b/superset/assets/javascripts/explore/propTypes/columnType.js
@@ -0,0 +1,6 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+  column_name: PropTypes.string.isRequired,
+  type: PropTypes.string,
+});
diff --git a/superset/assets/javascripts/explore/propTypes/savedMetricType.js 
b/superset/assets/javascripts/explore/propTypes/savedMetricType.js
new file mode 100644
index 0000000000..3e713a85d7
--- /dev/null
+++ b/superset/assets/javascripts/explore/propTypes/savedMetricType.js
@@ -0,0 +1,6 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+  metric_name: PropTypes.string.isRequired,
+  expression: PropTypes.string.isRequired,
+});
diff --git a/superset/assets/javascripts/explore/stores/controls.jsx 
b/superset/assets/javascripts/explore/stores/controls.jsx
index 617c717d5f..f0d00bf030 100644
--- a/superset/assets/javascripts/explore/stores/controls.jsx
+++ b/superset/assets/javascripts/explore/stores/controls.jsx
@@ -129,19 +129,18 @@ export const controls = {
   },
 
   metrics: {
-    type: 'SelectControl',
+    type: 'MetricsControl',
     multi: true,
     label: t('Metrics'),
     validators: [v.nonEmpty],
-    valueKey: 'metric_name',
-    optionRenderer: m => <MetricOption metric={m} showType />,
-    valueRenderer: m => <MetricOption metric={m} />,
     default: (c) => {
       const metric = mainMetric(c.options);
       return metric ? [metric] : null;
     },
     mapStateToProps: state => ({
-      options: (state.datasource) ? state.datasource.metrics : [],
+      columns: state.datasource ? state.datasource.columns : [],
+      savedMetrics: state.datasource ? state.datasource.metrics : [],
+      datasourceType: state.datasource && state.datasource.type,
     }),
     description: t('One or many metrics to display'),
   },
@@ -219,17 +218,16 @@ export const controls = {
   },
 
   metric: {
-    type: 'SelectControl',
+    type: 'MetricsControl',
+    multi: false,
     label: t('Metric'),
     clearable: false,
-    description: t('Choose the metric'),
     validators: [v.nonEmpty],
-    optionRenderer: m => <MetricOption metric={m} showType />,
-    valueRenderer: m => <MetricOption metric={m} />,
     default: c => mainMetric(c.options),
-    valueKey: 'metric_name',
     mapStateToProps: state => ({
-      options: (state.datasource) ? state.datasource.metrics : [],
+      columns: state.datasource ? state.datasource.columns : [],
+      savedMetrics: state.datasource ? state.datasource.metrics : [],
+      datasourceType: state.datasource && state.datasource.type,
     }),
   },
 
diff --git a/superset/assets/javascripts/explore/stores/visTypes.js 
b/superset/assets/javascripts/explore/stores/visTypes.js
index 75443469f3..d848f40e10 100644
--- a/superset/assets/javascripts/explore/stores/visTypes.js
+++ b/superset/assets/javascripts/explore/stores/visTypes.js
@@ -139,7 +139,8 @@ export const visTypes = {
         label: t('Query'),
         expanded: true,
         controlSetRows: [
-          ['metrics', 'groupby'],
+          ['metrics'],
+          ['groupby'],
           ['limit'],
         ],
       },
diff --git a/superset/assets/package.json b/superset/assets/package.json
index 7d41be3728..5bf03c037c 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -96,7 +96,7 @@
     "react-map-gl": "^3.0.4",
     "react-redux": "^5.0.2",
     "react-resizable": "^1.3.3",
-    "react-select": "1.0.0-rc.10",
+    "react-select": "1.2.1",
     "react-select-fast-filter-options": "^0.2.1",
     "react-sortable-hoc": "^0.6.7",
     "react-split-pane": "^0.1.66",
@@ -127,7 +127,7 @@
     "clean-webpack-plugin": "^0.1.16",
     "css-loader": "^0.28.0",
     "enzyme": "^2.0.0",
-    "eslint": "^3.19.0",
+    "eslint": "^4.19.0",
     "eslint-config-airbnb": "^15.0.1",
     "eslint-plugin-import": "^2.2.0",
     "eslint-plugin-jsx-a11y": "^5.1.1",
diff --git a/superset/assets/spec/javascripts/explore/AdhocMetric_spec.js 
b/superset/assets/spec/javascripts/explore/AdhocMetric_spec.js
new file mode 100644
index 0000000000..ad9ab47254
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/AdhocMetric_spec.js
@@ -0,0 +1,85 @@
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+
+import AdhocMetric from '../../../javascripts/explore/AdhocMetric';
+import { AGGREGATES } from '../../../javascripts/explore/constants';
+
+const valueColumn = { type: 'DOUBLE', column_name: 'value' };
+
+describe('AdhocMetric', () => {
+  it('sets label, hasCustomLabel and optionName in constructor', () => {
+    const adhocMetric = new AdhocMetric({
+      column: valueColumn,
+      aggregate: AGGREGATES.SUM,
+    });
+    expect(adhocMetric.optionName.length).to.be.above(10);
+    expect(adhocMetric).to.deep.equal({
+      column: valueColumn,
+      aggregate: AGGREGATES.SUM,
+      fromFormData: false,
+      label: 'SUM(value)',
+      hasCustomLabel: false,
+      optionName: adhocMetric.optionName,
+    });
+  });
+
+  it('can create altered duplicates', () => {
+    const adhocMetric1 = new AdhocMetric({
+      column: valueColumn,
+      aggregate: AGGREGATES.SUM,
+    });
+    const adhocMetric2 = adhocMetric1.duplicateWith({ aggregate: 
AGGREGATES.AVG });
+
+    expect(adhocMetric1.column).to.equal(adhocMetric2.column);
+    expect(adhocMetric1.column).to.equal(valueColumn);
+
+    expect(adhocMetric1.aggregate).to.equal(AGGREGATES.SUM);
+    expect(adhocMetric2.aggregate).to.equal(AGGREGATES.AVG);
+  });
+
+  it('can verify equality', () => {
+    const adhocMetric1 = new AdhocMetric({
+      column: valueColumn,
+      aggregate: AGGREGATES.SUM,
+    });
+    const adhocMetric2 = adhocMetric1.duplicateWith({});
+
+    // eslint-disable-next-line no-unused-expressions
+    expect(adhocMetric1.equals(adhocMetric2)).to.be.true;
+  });
+
+  it('can verify inequality', () => {
+    const adhocMetric1 = new AdhocMetric({
+      column: valueColumn,
+      aggregate: AGGREGATES.SUM,
+      label: 'old label',
+      hasCustomLabel: true,
+    });
+    const adhocMetric2 = adhocMetric1.duplicateWith({ label: 'new label' });
+
+    // eslint-disable-next-line no-unused-expressions
+    expect(adhocMetric1.equals(adhocMetric2)).to.be.false;
+  });
+
+  it('updates label if hasCustomLabel is false', () => {
+    const adhocMetric1 = new AdhocMetric({
+      column: valueColumn,
+      aggregate: AGGREGATES.SUM,
+    });
+    const adhocMetric2 = adhocMetric1.duplicateWith({ aggregate: 
AGGREGATES.AVG });
+
+    expect(adhocMetric2.label).to.equal('AVG(value)');
+  });
+
+  it('keeps label if hasCustomLabel is true', () => {
+    const adhocMetric1 = new AdhocMetric({
+      column: valueColumn,
+      aggregate: AGGREGATES.SUM,
+      hasCustomLabel: true,
+      label: 'label1',
+    });
+    const adhocMetric2 = adhocMetric1.duplicateWith({ aggregate: 
AGGREGATES.AVG });
+
+    expect(adhocMetric2.label).to.equal('label1');
+  });
+});
diff --git 
a/superset/assets/spec/javascripts/explore/components/AdhocMetricEditPopoverTitle_spec.jsx
 
b/superset/assets/spec/javascripts/explore/components/AdhocMetricEditPopoverTitle_spec.jsx
new file mode 100644
index 0000000000..b732a5a78f
--- /dev/null
+++ 
b/superset/assets/spec/javascripts/explore/components/AdhocMetricEditPopoverTitle_spec.jsx
@@ -0,0 +1,48 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { OverlayTrigger } from 'react-bootstrap';
+
+import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
+import AdhocMetricEditPopoverTitle from 
'../../../../javascripts/explore/components/AdhocMetricEditPopoverTitle';
+import { AGGREGATES } from '../../../../javascripts/explore/constants';
+
+const columns = [
+  { type: 'VARCHAR(255)', column_name: 'source' },
+  { type: 'VARCHAR(255)', column_name: 'target' },
+  { type: 'DOUBLE', column_name: 'value' },
+];
+
+const sumValueAdhocMetric = new AdhocMetric({
+  column: columns[2],
+  aggregate: AGGREGATES.SUM,
+});
+
+function setup(overrides) {
+  const onChange = sinon.spy();
+  const props = {
+    adhocMetric: sumValueAdhocMetric,
+    onChange,
+    ...overrides,
+  };
+  const wrapper = shallow(<AdhocMetricEditPopoverTitle {...props} />);
+  return { wrapper, onChange };
+}
+
+describe('AdhocMetricEditPopoverTitle', () => {
+  it('renders an OverlayTrigger wrapper with the title', () => {
+    const { wrapper } = setup();
+    expect(wrapper.find(OverlayTrigger)).to.have.lengthOf(1);
+    expect(wrapper.find(OverlayTrigger).dive().text()).to.equal('My 
Metric\xa0');
+  });
+
+  it('transfers to edit mode when clicked', () => {
+    const { wrapper } = setup();
+    expect(wrapper.state('isEditable')).to.be.false;
+    wrapper.simulate('click');
+    expect(wrapper.state('isEditable')).to.be.true;
+  });
+});
diff --git 
a/superset/assets/spec/javascripts/explore/components/AdhocMetricEditPopover_spec.jsx
 
b/superset/assets/spec/javascripts/explore/components/AdhocMetricEditPopover_spec.jsx
new file mode 100644
index 0000000000..0ba69fba0e
--- /dev/null
+++ 
b/superset/assets/spec/javascripts/explore/components/AdhocMetricEditPopover_spec.jsx
@@ -0,0 +1,90 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { Button, FormGroup, Popover } from 'react-bootstrap';
+
+import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
+import AdhocMetricEditPopover from 
'../../../../javascripts/explore/components/AdhocMetricEditPopover';
+import { AGGREGATES } from '../../../../javascripts/explore/constants';
+
+const columns = [
+  { type: 'VARCHAR(255)', column_name: 'source' },
+  { type: 'VARCHAR(255)', column_name: 'target' },
+  { type: 'DOUBLE', column_name: 'value' },
+];
+
+const sumValueAdhocMetric = new AdhocMetric({
+  column: columns[2],
+  aggregate: AGGREGATES.SUM,
+});
+
+function setup(overrides) {
+  const onChange = sinon.spy();
+  const onClose = sinon.spy();
+  const props = {
+    adhocMetric: sumValueAdhocMetric,
+    onChange,
+    onClose,
+    columns,
+    ...overrides,
+  };
+  const wrapper = shallow(<AdhocMetricEditPopover {...props} />);
+  return { wrapper, onChange, onClose };
+}
+
+describe('AdhocMetricEditPopover', () => {
+  it('renders a popover with edit metric form contents', () => {
+    const { wrapper } = setup();
+    expect(wrapper.find(Popover)).to.have.lengthOf(1);
+    expect(wrapper.find(FormGroup)).to.have.lengthOf(2);
+    expect(wrapper.find(Button)).to.have.lengthOf(2);
+  });
+
+  it('overwrites the adhocMetric in state with onColumnChange', () => {
+    const { wrapper } = setup();
+    wrapper.instance().onColumnChange(columns[0]);
+    
expect(wrapper.state('adhocMetric')).to.deep.equal(sumValueAdhocMetric.duplicateWith({
 column: columns[0] }));
+  });
+
+  it('overwrites the adhocMetric in state with onAggregateChange', () => {
+    const { wrapper } = setup();
+    wrapper.instance().onAggregateChange({ aggregate: AGGREGATES.AVG });
+    
expect(wrapper.state('adhocMetric')).to.deep.equal(sumValueAdhocMetric.duplicateWith({
 aggregate: AGGREGATES.AVG }));
+  });
+
+  it('overwrites the adhocMetric in state with onLabelChange', () => {
+    const { wrapper } = setup();
+    wrapper.instance().onLabelChange({ target: { value: 'new label' } });
+    expect(wrapper.state('adhocMetric').label).to.equal('new label');
+    expect(wrapper.state('adhocMetric').hasCustomLabel).to.be.true;
+  });
+
+  it('returns to default labels when the custom label is cleared', () => {
+    const { wrapper } = setup();
+    wrapper.instance().onLabelChange({ target: { value: 'new label' } });
+    wrapper.instance().onLabelChange({ target: { value: '' } });
+    expect(wrapper.state('adhocMetric').label).to.equal('SUM(value)');
+    expect(wrapper.state('adhocMetric').hasCustomLabel).to.be.false;
+  });
+
+  it('prevents saving if no column or aggregate is chosen', () => {
+    const { wrapper } = setup();
+    expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(0);
+    wrapper.instance().onColumnChange(null);
+    expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(1);
+    wrapper.instance().onColumnChange({ column: columns[0] });
+    expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(0);
+    wrapper.instance().onAggregateChange(null);
+    expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(1);
+  });
+
+  it('highlights save if changes are present', () => {
+    const { wrapper } = setup();
+    expect(wrapper.find(Button).find({ bsStyle: 'primary' 
})).to.have.lengthOf(0);
+    wrapper.instance().onColumnChange({ column: columns[1] });
+    expect(wrapper.find(Button).find({ bsStyle: 'primary' 
})).to.have.lengthOf(1);
+  });
+});
diff --git 
a/superset/assets/spec/javascripts/explore/components/AdhocMetricOption_spec.jsx
 
b/superset/assets/spec/javascripts/explore/components/AdhocMetricOption_spec.jsx
new file mode 100644
index 0000000000..ac36825984
--- /dev/null
+++ 
b/superset/assets/spec/javascripts/explore/components/AdhocMetricOption_spec.jsx
@@ -0,0 +1,42 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { Label, OverlayTrigger } from 'react-bootstrap';
+
+import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
+import AdhocMetricOption from 
'../../../../javascripts/explore/components/AdhocMetricOption';
+import { AGGREGATES } from '../../../../javascripts/explore/constants';
+
+const columns = [
+  { type: 'VARCHAR(255)', column_name: 'source' },
+  { type: 'VARCHAR(255)', column_name: 'target' },
+  { type: 'DOUBLE', column_name: 'value' },
+];
+
+const sumValueAdhocMetric = new AdhocMetric({
+  column: columns[2],
+  aggregate: AGGREGATES.SUM,
+});
+
+function setup(overrides) {
+  const onMetricEdit = sinon.spy();
+  const props = {
+    adhocMetric: sumValueAdhocMetric,
+    onMetricEdit,
+    columns,
+    ...overrides,
+  };
+  const wrapper = shallow(<AdhocMetricOption {...props} />);
+  return { wrapper, onMetricEdit };
+}
+
+describe('AdhocMetricOption', () => {
+  it('renders an overlay trigger wrapper for the label', () => {
+    const { wrapper } = setup();
+    expect(wrapper.find(OverlayTrigger)).to.have.lengthOf(1);
+    expect(wrapper.find(Label)).to.have.lengthOf(1);
+  });
+});
diff --git 
a/superset/assets/spec/javascripts/explore/components/AggregateOption_spec.jsx 
b/superset/assets/spec/javascripts/explore/components/AggregateOption_spec.jsx
new file mode 100644
index 0000000000..a1fb317969
--- /dev/null
+++ 
b/superset/assets/spec/javascripts/explore/components/AggregateOption_spec.jsx
@@ -0,0 +1,14 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import AggregateOption from 
'../../../../javascripts/explore/components/AggregateOption';
+
+describe('AggregateOption', () => {
+  it('renders the aggregate', () => {
+    const wrapper = shallow(<AggregateOption aggregate={{ aggregate_name: 
'SUM' }} />);
+    expect(wrapper.text()).to.equal('SUM');
+  });
+});
diff --git 
a/superset/assets/spec/javascripts/explore/components/MetricDefinitionOption_spec.jsx
 
b/superset/assets/spec/javascripts/explore/components/MetricDefinitionOption_spec.jsx
new file mode 100644
index 0000000000..e39c225b6a
--- /dev/null
+++ 
b/superset/assets/spec/javascripts/explore/components/MetricDefinitionOption_spec.jsx
@@ -0,0 +1,27 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import MetricDefinitionOption from 
'../../../../javascripts/explore/components/MetricDefinitionOption';
+import MetricOption from '../../../../javascripts/components/MetricOption';
+import ColumnOption from '../../../../javascripts/components/ColumnOption';
+import AggregateOption from 
'../../../../javascripts/explore/components/AggregateOption';
+
+describe('MetricDefinitionOption', () => {
+  it('renders a MetricOption given a saved metric', () => {
+    const wrapper = shallow(<MetricDefinitionOption option={{ metric_name: 
'a_saved_metric' }} />);
+    expect(wrapper.find(MetricOption)).to.have.lengthOf(1);
+  });
+
+  it('renders a ColumnOption given a column', () => {
+    const wrapper = shallow(<MetricDefinitionOption option={{ column_name: 
'a_column' }} />);
+    expect(wrapper.find(ColumnOption)).to.have.lengthOf(1);
+  });
+
+  it('renders an AggregateOption given an aggregate metric', () => {
+    const wrapper = shallow(<MetricDefinitionOption option={{ aggregate_name: 
'an_aggregate' }} />);
+    expect(wrapper.find(AggregateOption)).to.have.lengthOf(1);
+  });
+});
diff --git 
a/superset/assets/spec/javascripts/explore/components/MetricDefinitionValue_spec.jsx
 
b/superset/assets/spec/javascripts/explore/components/MetricDefinitionValue_spec.jsx
new file mode 100644
index 0000000000..5690c21010
--- /dev/null
+++ 
b/superset/assets/spec/javascripts/explore/components/MetricDefinitionValue_spec.jsx
@@ -0,0 +1,30 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import MetricDefinitionValue from 
'../../../../javascripts/explore/components/MetricDefinitionValue';
+import MetricOption from '../../../../javascripts/components/MetricOption';
+import AdhocMetricOption from 
'../../../../javascripts/explore/components/AdhocMetricOption';
+import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
+import { AGGREGATES } from '../../../../javascripts/explore/constants';
+
+const sumValueAdhocMetric = new AdhocMetric({
+  column: { type: 'DOUBLE', column_name: 'value' },
+  aggregate: AGGREGATES.SUM,
+});
+
+describe('MetricDefinitionValue', () => {
+  it('renders a MetricOption given a saved metric', () => {
+    const wrapper = shallow(<MetricDefinitionValue option={{ metric_name: 
'a_saved_metric' }} />);
+    expect(wrapper.find(MetricOption)).to.have.lengthOf(1);
+  });
+
+  it('renders an AdhocMetricOption given an adhoc metric', () => {
+    const wrapper = shallow((
+      <MetricDefinitionValue onMetricEdit={() => {}} 
option={sumValueAdhocMetric} />
+    ));
+    expect(wrapper.find(AdhocMetricOption)).to.have.lengthOf(1);
+  });
+});
diff --git 
a/superset/assets/spec/javascripts/explore/components/MetricsControl_spec.jsx 
b/superset/assets/spec/javascripts/explore/components/MetricsControl_spec.jsx
new file mode 100644
index 0000000000..346a2872cd
--- /dev/null
+++ 
b/superset/assets/spec/javascripts/explore/components/MetricsControl_spec.jsx
@@ -0,0 +1,250 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import MetricsControl from 
'../../../../javascripts/explore/components/controls/MetricsControl';
+import { AGGREGATES } from '../../../../javascripts/explore/constants';
+import OnPasteSelect from '../../../../javascripts/components/OnPasteSelect';
+import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
+
+const defaultProps = {
+  name: 'metrics',
+  label: 'Metrics',
+  value: undefined,
+  multi: true,
+  columns: [
+    { type: 'VARCHAR(255)', column_name: 'source' },
+    { type: 'VARCHAR(255)', column_name: 'target' },
+    { type: 'DOUBLE', column_name: 'value' },
+  ],
+  savedMetrics: [
+    { metric_name: 'sum__value', expression: 'SUM(energy_usage.value)' },
+    { metric_name: 'avg__value', expression: 'AVG(energy_usage.value)' },
+  ],
+};
+
+function setup(overrides) {
+  const onChange = sinon.spy();
+  const props = {
+    onChange,
+    ...defaultProps,
+    ...overrides,
+  };
+  const wrapper = shallow(<MetricsControl {...props} />);
+  return { wrapper, onChange };
+}
+
+const valueColumn = { type: 'DOUBLE', column_name: 'value' };
+
+const sumValueAdhocMetric = new AdhocMetric({
+  column: valueColumn,
+  aggregate: AGGREGATES.SUM,
+  label: 'SUM(value)',
+});
+
+describe('MetricsControl', () => {
+
+  it('renders an OnPasteSelect', () => {
+    const { wrapper } = setup();
+    expect(wrapper.find(OnPasteSelect)).to.have.lengthOf(1);
+  });
+
+  describe('constructor', () => {
+
+    it('unifies options for the dropdown select with aggregates', () => {
+      const { wrapper } = setup();
+      expect(wrapper.state('options')).to.deep.equal([
+        { optionName: '_col_source', type: 'VARCHAR(255)', column_name: 
'source' },
+        { optionName: '_col_target', type: 'VARCHAR(255)', column_name: 
'target' },
+        { optionName: '_col_value', type: 'DOUBLE', column_name: 'value' },
+        ...Object.keys(AGGREGATES).map(
+          aggregate => ({ aggregate_name: aggregate, optionName: '_aggregate_' 
+ aggregate }),
+        ),
+        { optionName: 'sum__value', metric_name: 'sum__value', expression: 
'SUM(energy_usage.value)' },
+        { optionName: 'avg__value', metric_name: 'avg__value', expression: 
'AVG(energy_usage.value)' },
+      ]);
+    });
+
+    it('coerces Adhoc Metrics from form data into instances of the AdhocMetric 
class and leaves saved metrics', () => {
+      const { wrapper } = setup({
+        value: [
+          {
+            column: { type: 'double', column_name: 'value' },
+            aggregate: AGGREGATES.SUM,
+            label: 'SUM(value)',
+            optionName: 'blahblahblah',
+          },
+          'avg__value',
+        ],
+      });
+
+      const adhocMetric = wrapper.state('value')[0];
+      expect(adhocMetric instanceof AdhocMetric).to.be.true;
+      expect(adhocMetric.optionName.length).to.be.above(10);
+      expect(wrapper.state('value')).to.deep.equal([
+        {
+          column: { type: 'double', column_name: 'value' },
+          aggregate: AGGREGATES.SUM,
+          fromFormData: true,
+          label: 'SUM(value)',
+          hasCustomLabel: false,
+          optionName: 'blahblahblah',
+        },
+        'avg__value',
+      ]);
+    });
+
+  });
+
+  describe('onChange', () => {
+
+    it('handles saved metrics being selected', () => {
+      const { wrapper, onChange } = setup();
+      const select = wrapper.find(OnPasteSelect);
+      select.simulate('change', [{ metric_name: 'sum__value' }]);
+      expect(onChange.lastCall.args).to.deep.equal([['sum__value']]);
+    });
+
+    it('handles columns being selected', () => {
+      const { wrapper, onChange } = setup();
+      const select = wrapper.find(OnPasteSelect);
+      select.simulate('change', [valueColumn]);
+
+      const adhocMetric = onChange.lastCall.args[0][0];
+      expect(adhocMetric instanceof AdhocMetric).to.be.true;
+      expect(onChange.lastCall.args).to.deep.equal([[{
+        column: valueColumn,
+        aggregate: AGGREGATES.SUM,
+        label: 'SUM(value)',
+        fromFormData: false,
+        hasCustomLabel: false,
+        optionName: adhocMetric.optionName,
+      }]]);
+    });
+
+    it('handles aggregates being selected', () => {
+      const { wrapper, onChange } = setup();
+      const select = wrapper.find(OnPasteSelect);
+
+      // mock out the Select ref
+      const setInputSpy = sinon.spy();
+      const handleInputSpy = sinon.spy();
+      wrapper.instance().select = {
+        setInputValue: setInputSpy,
+        handleInputChange: handleInputSpy,
+        input: { input: {} },
+      };
+
+      select.simulate('change', [{ aggregate_name: 'SUM', optionName: 'SUM' 
}]);
+
+      expect(setInputSpy.calledWith('SUM()')).to.be.true;
+      expect(handleInputSpy.calledWith({ target: { value: 'SUM()' } 
})).to.be.true;
+      expect(onChange.lastCall.args).to.deep.equal([[]]);
+    });
+
+    it('preserves existing selected AdhocMetrics', () => {
+      const { wrapper, onChange } = setup();
+      const select = wrapper.find(OnPasteSelect);
+      select.simulate('change', [{ metric_name: 'sum__value' }, 
sumValueAdhocMetric]);
+      expect(onChange.lastCall.args).to.deep.equal([['sum__value', 
sumValueAdhocMetric]]);
+    });
+  });
+
+  describe('onMetricEdit', () => {
+    it('accepts an edited metric from an AdhocMetricEditPopover', () => {
+      const { wrapper, onChange } = setup({
+        value: [sumValueAdhocMetric],
+      });
+
+      const editedMetric = sumValueAdhocMetric.duplicateWith({ aggregate: 
AGGREGATES.AVG });
+      wrapper.instance().onMetricEdit(editedMetric);
+
+      expect(onChange.lastCall.args).to.deep.equal([[
+        editedMetric,
+      ]]);
+    });
+  });
+
+  describe('checkIfAggregateInInput', () => {
+    it('handles an aggregate in the input', () => {
+      const { wrapper } = setup();
+
+      expect(wrapper.state('aggregateInInput')).to.be.null;
+      wrapper.instance().checkIfAggregateInInput('AVG(');
+      expect(wrapper.state('aggregateInInput')).to.equal(AGGREGATES.AVG);
+    });
+
+    it('handles no aggregate in the input', () => {
+      const { wrapper } = setup();
+
+      expect(wrapper.state('aggregateInInput')).to.be.null;
+      wrapper.instance().checkIfAggregateInInput('colu');
+      expect(wrapper.state('aggregateInInput')).to.be.null;
+    });
+  });
+
+  describe('option filter', () => {
+    it('includes user defined metrics', () => {
+      const { wrapper } = setup();
+
+      expect(!!wrapper.instance().selectFilterOption(
+        {
+          metric_name: 'a_metric',
+          optionName: 'a_metric',
+          expression: 'SUM(FANCY(metric))',
+        },
+        'a',
+      )).to.be.true;
+    });
+
+    it('includes columns and aggregates', () => {
+      const { wrapper } = setup();
+
+      expect(!!wrapper.instance().selectFilterOption(
+        { type: 'VARCHAR(255)', column_name: 'source', optionName: 
'_col_source' },
+        'Sou',
+      )).to.be.true;
+
+      expect(!!wrapper.instance().selectFilterOption(
+        { aggregate_name: 'AVG', optionName: '_aggregate_AVG' },
+        'av',
+      )).to.be.true;
+    });
+
+    it('excludes auto generated metrics', () => {
+      const { wrapper } = setup();
+
+      expect(!!wrapper.instance().selectFilterOption(
+        {
+          metric_name: 'sum__value',
+          optionName: 'sum__value',
+          expression: 'SUM(value)',
+        },
+        'sum',
+      )).to.be.false;
+    });
+
+    it('filters out metrics if the input begins with an aggregate', () => {
+      const { wrapper } = setup();
+      wrapper.setState({ aggregateInInput: true });
+
+      expect(!!wrapper.instance().selectFilterOption(
+        { metric_name: 'metric', expression: 'SUM(FANCY(metric))' },
+        'SUM(',
+      )).to.be.false;
+    });
+
+    it('includes columns if the input begins with an aggregate', () => {
+      const { wrapper } = setup();
+      wrapper.setState({ aggregateInInput: true });
+
+      expect(!!wrapper.instance().selectFilterOption(
+        { type: 'DOUBLE', column_name: 'value' },
+        'SUM(',
+      )).to.be.true;
+    });
+  });
+});
diff --git a/superset/assets/stylesheets/superset.less 
b/superset/assets/stylesheets/superset.less
index 4ac5ba8ae2..035acceb1f 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -449,3 +449,9 @@ g.annotation-container {
   color: @brand-primary;
   border-color: @brand-primary;
 }
+
+.metric-edit-popover-label-input {
+  border-radius: 4px;
+  height: 30px;
+  padding-left: 10px;
+}
diff --git a/superset/connectors/druid/models.py 
b/superset/connectors/druid/models.py
index 107e3c84ff..d514a2f4ee 100644
--- a/superset/connectors/druid/models.py
+++ b/superset/connectors/druid/models.py
@@ -897,13 +897,16 @@ def resolve_postagg(postagg, post_aggs, agg_names, 
visited_postaggs, metrics_dic
     def metrics_and_post_aggs(metrics, metrics_dict):
         # Separate metrics into those that are aggregations
         # and those that are post aggregations
-        agg_names = set()
+        saved_agg_names = set()
+        adhoc_agg_configs = []
         postagg_names = []
-        for metric_name in metrics:
-            if metrics_dict[metric_name].metric_type != 'postagg':
-                agg_names.add(metric_name)
+        for metric in metrics:
+            if utils.is_adhoc_metric(metric):
+                adhoc_agg_configs.append(metric)
+            elif metrics_dict[metric].metric_type != 'postagg':
+                saved_agg_names.add(metric)
             else:
-                postagg_names.append(metric_name)
+                postagg_names.append(metric)
         # Create the post aggregations, maintain order since postaggs
         # may depend on previous ones
         post_aggs = OrderedDict()
@@ -912,8 +915,8 @@ def metrics_and_post_aggs(metrics, metrics_dict):
             postagg = metrics_dict[postagg_name]
             visited_postaggs.add(postagg_name)
             DruidDatasource.resolve_postagg(
-                postagg, post_aggs, agg_names, visited_postaggs, metrics_dict)
-        return list(agg_names), post_aggs
+                postagg, post_aggs, saved_agg_names, visited_postaggs, 
metrics_dict)
+        return list(saved_agg_names), adhoc_agg_configs, post_aggs
 
     def values_for_column(self,
                           column_name,
@@ -968,11 +971,29 @@ def _add_filter_from_pre_query_data(self, df, dimensions, 
dim_filter):
                     ret = Filter(type='and', fields=[ff, dim_filter])
         return ret
 
-    def get_aggregations(self, all_metrics):
+    @staticmethod
+    def druid_type_from_adhoc_metric(adhoc_metric):
+        column_type = adhoc_metric.get('column').get('type').lower()
+        aggregate = adhoc_metric.get('aggregate').lower()
+        if (aggregate == 'count'):
+            return 'count'
+        if (aggregate == 'count_distinct'):
+            return 'cardinality'
+        else:
+            return column_type + aggregate.capitalize()
+
+    def get_aggregations(self, saved_metrics, adhoc_metrics=[]):
         aggregations = OrderedDict()
         for m in self.metrics:
-            if m.metric_name in all_metrics:
+            if m.metric_name in saved_metrics:
                 aggregations[m.metric_name] = m.json_obj
+        for adhoc_metric in adhoc_metrics:
+            aggregations[adhoc_metric['label']] = {
+                'fieldName': adhoc_metric['column']['column_name'],
+                'fieldNames': [adhoc_metric['column']['column_name']],
+                'type': self.druid_type_from_adhoc_metric(adhoc_metric),
+                'name': adhoc_metric['label'],
+            }
         return aggregations
 
     def check_restricted_metrics(self, aggregations):
@@ -1066,11 +1087,11 @@ def run_query(  # noqa / druid
         metrics_dict = {m.metric_name: m for m in self.metrics}
         columns_dict = {c.column_name: c for c in self.columns}
 
-        all_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
+        saved_metrics, adhoc_metrics, post_aggs = 
DruidDatasource.metrics_and_post_aggs(
             metrics,
             metrics_dict)
 
-        aggregations = self.get_aggregations(all_metrics)
+        aggregations = self.get_aggregations(saved_metrics, adhoc_metrics)
         self.check_restricted_metrics(aggregations)
 
         # the dimensions list with dimensionSpecs expanded
@@ -1246,6 +1267,7 @@ def query(self, query_obj):
         cols += query_obj.get('columns') or []
         cols += query_obj.get('metrics') or []
 
+        cols = utils.get_metric_names(cols)
         cols = [col for col in cols if col in df.columns]
         df = df[cols]
 
diff --git a/superset/connectors/sqla/models.py 
b/superset/connectors/sqla/models.py
index e2dca32137..c8ee40269c 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -278,6 +278,15 @@ class SqlaTable(Model, BaseDatasource):
     export_parent = 'database'
     export_children = ['metrics', 'columns']
 
+    sqla_aggregations = {
+        'COUNT_DISTINCT': lambda column_name: 
sa.func.COUNT(sa.distinct(column_name)),
+        'COUNT': sa.func.COUNT,
+        'SUM': sa.func.SUM,
+        'AVG': sa.func.AVG,
+        'MIN': sa.func.MIN,
+        'MAX': sa.func.MAX,
+    }
+
     def __repr__(self):
         return self.name
 
@@ -436,6 +445,12 @@ def get_from_clause(self, template_processor=None, 
db_engine_spec=None):
             return TextAsFrom(sa.text(from_sql), []).alias('expr_qry')
         return self.get_sqla_table()
 
+    def adhoc_metric_to_sa(self, metric):
+        column_name = metric.get('column').get('column_name')
+        sa_metric = 
self.sqla_aggregations[metric.get('aggregate')](column(column_name))
+        sa_metric = sa_metric.label(metric.get('label'))
+        return sa_metric
+
     def get_sqla_query(  # sqla
             self,
             groupby, metrics,
@@ -484,10 +499,14 @@ def get_sqla_query(  # sqla
                 'and is required by this type of chart'))
         if not groupby and not metrics and not columns:
             raise Exception(_('Empty query?'))
+        metrics_exprs = []
         for m in metrics:
-            if m not in metrics_dict:
+            if utils.is_adhoc_metric(m):
+                metrics_exprs.append(self.adhoc_metric_to_sa(m))
+            elif m in metrics_dict:
+                metrics_exprs.append(metrics_dict.get(m).sqla_col)
+            else:
                 raise Exception(_("Metric '{}' is not valid".format(m)))
-        metrics_exprs = [metrics_dict.get(m).sqla_col for m in metrics]
         if metrics_exprs:
             main_metric_expr = metrics_exprs[0]
         else:
diff --git a/superset/utils.py b/superset/utils.py
index 8e91e8dc80..4163739bd4 100644
--- a/superset/utils.py
+++ b/superset/utils.py
@@ -832,8 +832,7 @@ def get_or_create_main_db():
     dbobj = (
         db.session.query(models.Database)
         .filter_by(database_name='main')
-        .first()
-    )
+        .first())
     if not dbobj:
         dbobj = models.Database(database_name='main')
     dbobj.set_sqlalchemy_uri(conf.get('SQLALCHEMY_DATABASE_URI'))
@@ -842,3 +841,14 @@ def get_or_create_main_db():
     db.session.add(dbobj)
     db.session.commit()
     return dbobj
+
+
+def is_adhoc_metric(metric):
+    return (isinstance(metric, dict) and
+            metric['column'] and
+            metric['aggregate'] and
+            metric['label'])
+
+
+def get_metric_names(metrics):
+    return [metric['label'] if is_adhoc_metric(metric) else metric for metric 
in metrics]
diff --git a/superset/viz.py b/superset/viz.py
index fc87430161..5d98d5e60e 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -64,7 +64,14 @@ def __init__(self, datasource, form_data, force=False):
         self.query = ''
         self.token = self.form_data.get(
             'token', 'token_' + uuid.uuid4().hex[:8])
-        self.metrics = self.form_data.get('metrics') or []
+        metrics = self.form_data.get('metrics') or []
+        self.metrics = []
+        for metric in metrics:
+            if isinstance(metric, dict):
+                self.metrics.append(metric['label'])
+            else:
+                self.metrics.append(metric)
+
         self.groupby = self.form_data.get('groupby') or []
         self.time_shift = timedelta()
 
@@ -1058,12 +1065,12 @@ def process_data(self, df, aggregate=False):
             df = df.pivot_table(
                 index=DTTM_ALIAS,
                 columns=fd.get('groupby'),
-                values=fd.get('metrics'))
+                values=utils.get_metric_names(fd.get('metrics')))
         else:
             df = df.pivot_table(
                 index=DTTM_ALIAS,
                 columns=fd.get('groupby'),
-                values=fd.get('metrics'),
+                values=utils.get_metric_names(fd.get('metrics')),
                 fill_value=0,
                 aggfunc=sum)
 
diff --git a/tests/druid_func_tests.py b/tests/druid_func_tests.py
index 5b535e9b71..22c1f38dc9 100644
--- a/tests/druid_func_tests.py
+++ b/tests/druid_func_tests.py
@@ -180,6 +180,51 @@ def test_run_query_no_groupby(self):
         self.assertIn('post_aggregations', called_args)
         # restore functions
 
+    def test_run_query_with_adhoc_metric(self):
+        client = Mock()
+        from_dttm = Mock()
+        to_dttm = Mock()
+        from_dttm.replace = Mock(return_value=from_dttm)
+        to_dttm.replace = Mock(return_value=to_dttm)
+        from_dttm.isoformat = Mock(return_value='from')
+        to_dttm.isoformat = Mock(return_value='to')
+        timezone = 'timezone'
+        from_dttm.tzname = Mock(return_value=timezone)
+        ds = DruidDatasource(datasource_name='datasource')
+        metric1 = DruidMetric(metric_name='metric1')
+        metric2 = DruidMetric(metric_name='metric2')
+        ds.metrics = [metric1, metric2]
+        col1 = DruidColumn(column_name='col1')
+        col2 = DruidColumn(column_name='col2')
+        ds.columns = [col1, col2]
+        all_metrics = []
+        post_aggs = ['some_agg']
+        ds._metrics_and_post_aggs = Mock(return_value=(all_metrics, post_aggs))
+        groupby = []
+        metrics = [{
+            'column': {'type': 'DOUBLE', 'column_name': 'col1'},
+            'aggregate': 'SUM',
+            'label': 'My Adhoc Metric',
+        }]
+
+        ds.get_having_filters = Mock(return_value=[])
+        client.query_builder = Mock()
+        client.query_builder.last_query = Mock()
+        client.query_builder.last_query.query_dict = {'mock': 0}
+        # no groupby calls client.timeseries
+        ds.run_query(
+            groupby, metrics, None, from_dttm,
+            to_dttm, client=client, filter=[], row_limit=100,
+        )
+        self.assertEqual(0, len(client.topn.call_args_list))
+        self.assertEqual(0, len(client.groupby.call_args_list))
+        self.assertEqual(1, len(client.timeseries.call_args_list))
+        # check that there is no dimensions entry
+        called_args = client.timeseries.call_args_list[0][1]
+        self.assertNotIn('dimensions', called_args)
+        self.assertIn('post_aggregations', called_args)
+        # restore functions
+
     def test_run_query_single_groupby(self):
         client = Mock()
         from_dttm = Mock()
@@ -467,7 +512,7 @@ def depends_on(index, fields):
         depends_on('I', ['H', 'K'])
         depends_on('J', 'K')
         depends_on('K', ['m8', 'm9'])
-        all_metrics, postaggs = DruidDatasource.metrics_and_post_aggs(
+        all_metrics, saved_metrics, postaggs = 
DruidDatasource.metrics_and_post_aggs(
             metrics, metrics_dict)
         expected_metrics = set(all_metrics)
         self.assertEqual(9, len(all_metrics))
@@ -541,25 +586,80 @@ def test_metrics_and_post_aggs(self):
             ),
         }
 
+        adhoc_metric = {
+            'column': {'type': 'DOUBLE', 'column_name': 'value'},
+            'aggregate': 'SUM',
+            'label': 'My Adhoc Metric',
+        }
+
         metrics = ['some_sum']
-        all_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
+        saved_metrics, adhoc_metrics, post_aggs = 
DruidDatasource.metrics_and_post_aggs(
+            metrics, metrics_dict)
+
+        assert saved_metrics == ['some_sum']
+        assert adhoc_metrics == []
+        assert post_aggs == {}
+
+        metrics = [adhoc_metric]
+        saved_metrics, adhoc_metrics, post_aggs = 
DruidDatasource.metrics_and_post_aggs(
+            metrics, metrics_dict)
+
+        assert saved_metrics == []
+        assert adhoc_metrics == [adhoc_metric]
+        assert post_aggs == {}
+
+        metrics = ['some_sum', adhoc_metric]
+        saved_metrics, adhoc_metrics, post_aggs = 
DruidDatasource.metrics_and_post_aggs(
             metrics, metrics_dict)
 
-        assert all_metrics == ['some_sum']
+        assert saved_metrics == ['some_sum']
+        assert adhoc_metrics == [adhoc_metric]
         assert post_aggs == {}
 
         metrics = ['quantile_p95']
-        all_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
+        saved_metrics, adhoc_metrics, post_aggs = 
DruidDatasource.metrics_and_post_aggs(
             metrics, metrics_dict)
 
         result_postaggs = set(['quantile_p95'])
-        assert all_metrics == ['a_histogram']
+        assert saved_metrics == ['a_histogram']
+        assert adhoc_metrics == []
         assert set(post_aggs.keys()) == result_postaggs
 
         metrics = ['aCustomPostAgg']
-        all_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
+        saved_metrics, adhoc_metrics, post_aggs = 
DruidDatasource.metrics_and_post_aggs(
             metrics, metrics_dict)
 
         result_postaggs = set(['aCustomPostAgg'])
-        assert all_metrics == ['aCustomMetric']
+        assert saved_metrics == ['aCustomMetric']
+        assert adhoc_metrics == []
         assert set(post_aggs.keys()) == result_postaggs
+
+    def test_druid_type_from_adhoc_metric(self):
+
+        druid_type = DruidDatasource.druid_type_from_adhoc_metric({
+            'column': {'type': 'DOUBLE', 'column_name': 'value'},
+            'aggregate': 'SUM',
+            'label': 'My Adhoc Metric',
+        })
+        assert(druid_type == 'doubleSum')
+
+        druid_type = DruidDatasource.druid_type_from_adhoc_metric({
+            'column': {'type': 'LONG', 'column_name': 'value'},
+            'aggregate': 'MAX',
+            'label': 'My Adhoc Metric',
+        })
+        assert(druid_type == 'longMax')
+
+        druid_type = DruidDatasource.druid_type_from_adhoc_metric({
+            'column': {'type': 'VARCHAR(255)', 'column_name': 'value'},
+            'aggregate': 'COUNT',
+            'label': 'My Adhoc Metric',
+        })
+        assert(druid_type == 'count')
+
+        druid_type = DruidDatasource.druid_type_from_adhoc_metric({
+            'column': {'type': 'VARCHAR(255)', 'column_name': 'value'},
+            'aggregate': 'COUNT_DISTINCT',
+            'label': 'My Adhoc Metric',
+        })
+        assert(druid_type == 'cardinality')


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
[email protected]


With regards,
Apache Git Services

Reply via email to