mirror of https://github.com/apache/superset.git
[Explore] Streamlined metric definitions for SQLA and Druid (#4663)
* adding streamlined metric editing * addressing lint issues on new metrics control * enabling druid
This commit is contained in:
parent
7e1b6b7363
commit
68dec24542
|
@ -38,5 +38,8 @@
|
||||||
"react/no-unescaped-entities": 0,
|
"react/no-unescaped-entities": 0,
|
||||||
"react/no-unused-prop-types": 0,
|
"react/no-unused-prop-types": 0,
|
||||||
"react/no-string-refs": 0,
|
"react/no-string-refs": 0,
|
||||||
|
"indent": 0,
|
||||||
|
"no-multi-spaces": 0,
|
||||||
|
"padded-blocks": 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,16 +2,20 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
type: PropTypes.string.isRequired,
|
type: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ColumnTypeLabel({ type }) {
|
export default function ColumnTypeLabel({ type }) {
|
||||||
let stringIcon = '';
|
let stringIcon = '';
|
||||||
if (type === '' || type === 'expression') {
|
if (typeof type !== 'string') {
|
||||||
|
stringIcon = '?';
|
||||||
|
} else if (type === '' || type === 'expression') {
|
||||||
stringIcon = 'ƒ';
|
stringIcon = 'ƒ';
|
||||||
|
} else if (type === 'aggregate') {
|
||||||
|
stringIcon = 'AGG';
|
||||||
} else if (type.match(/.*char.*/i) || type.match(/string.*/i) || type.match(/.*text.*/i)) {
|
} else if (type.match(/.*char.*/i) || type.match(/string.*/i) || type.match(/.*text.*/i)) {
|
||||||
stringIcon = 'ABC';
|
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 = '#';
|
stringIcon = '#';
|
||||||
} else if (type.match(/.*bool.*/i)) {
|
} else if (type.match(/.*bool.*/i)) {
|
||||||
stringIcon = 'T/F';
|
stringIcon = 'T/F';
|
||||||
|
|
|
@ -49,8 +49,8 @@ export default class OnPasteSelect extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
const SelectComponent = this.props.selectWrap;
|
const SelectComponent = this.props.selectWrap;
|
||||||
const refFunc = (ref) => {
|
const refFunc = (ref) => {
|
||||||
if (this.props.ref) {
|
if (this.props.refFunc) {
|
||||||
this.props.ref(ref);
|
this.props.refFunc(ref);
|
||||||
}
|
}
|
||||||
this.pasteInput = ref;
|
this.pasteInput = ref;
|
||||||
};
|
};
|
||||||
|
@ -68,7 +68,7 @@ export default class OnPasteSelect extends React.Component {
|
||||||
OnPasteSelect.propTypes = {
|
OnPasteSelect.propTypes = {
|
||||||
separator: PropTypes.string.isRequired,
|
separator: PropTypes.string.isRequired,
|
||||||
selectWrap: PropTypes.func.isRequired,
|
selectWrap: PropTypes.func.isRequired,
|
||||||
ref: PropTypes.func,
|
refFunc: PropTypes.func,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
valueKey: PropTypes.string.isRequired,
|
valueKey: PropTypes.string.isRequired,
|
||||||
labelKey: PropTypes.string.isRequired,
|
labelKey: PropTypes.string.isRequired,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable camelcase */
|
||||||
import { combineReducers } from 'redux';
|
import { combineReducers } from 'redux';
|
||||||
import d3 from 'd3';
|
import d3 from 'd3';
|
||||||
import shortid from 'shortid';
|
import shortid from 'shortid';
|
||||||
|
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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'}
|
||||||
|
|
||||||
|
<i className="fa fa-pencil" style={{ color: this.state.isHovered ? 'black' : 'grey' }} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdhocMetricEditPopoverTitle.propTypes = propTypes;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -17,6 +17,7 @@ import TextControl from './TextControl';
|
||||||
import TimeSeriesColumnControl from './TimeSeriesColumnControl';
|
import TimeSeriesColumnControl from './TimeSeriesColumnControl';
|
||||||
import ViewportControl from './ViewportControl';
|
import ViewportControl from './ViewportControl';
|
||||||
import VizTypeControl from './VizTypeControl';
|
import VizTypeControl from './VizTypeControl';
|
||||||
|
import MetricsControl from './MetricsControl';
|
||||||
|
|
||||||
const controlMap = {
|
const controlMap = {
|
||||||
AnnotationLayerControl,
|
AnnotationLayerControl,
|
||||||
|
@ -38,5 +39,6 @@ const controlMap = {
|
||||||
TimeSeriesColumnControl,
|
TimeSeriesColumnControl,
|
||||||
ViewportControl,
|
ViewportControl,
|
||||||
VizTypeControl,
|
VizTypeControl,
|
||||||
|
MetricsControl,
|
||||||
};
|
};
|
||||||
export default controlMap;
|
export default controlMap;
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const AGGREGATES = {
|
||||||
|
AVG: 'AVG',
|
||||||
|
COUNT: 'COUNT ',
|
||||||
|
COUNT_DISTINCT: 'COUNT_DISTINCT',
|
||||||
|
MAX: 'MAX',
|
||||||
|
MIN: 'MIN',
|
||||||
|
SUM: 'SUM',
|
||||||
|
};
|
||||||
|
|
|
@ -39,6 +39,10 @@
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-panel-section .metrics-select .Select-multi-value-wrapper .Select-input > input {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
.background-transparent {
|
.background-transparent {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
|
@ -0,0 +1,5 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
export default PropTypes.shape({
|
||||||
|
aggregate_name: PropTypes.string.isRequired,
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
export default PropTypes.shape({
|
||||||
|
column_name: PropTypes.string.isRequired,
|
||||||
|
type: PropTypes.string,
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
export default PropTypes.shape({
|
||||||
|
metric_name: PropTypes.string.isRequired,
|
||||||
|
expression: PropTypes.string.isRequired,
|
||||||
|
});
|
|
@ -129,19 +129,18 @@ export const controls = {
|
||||||
},
|
},
|
||||||
|
|
||||||
metrics: {
|
metrics: {
|
||||||
type: 'SelectControl',
|
type: 'MetricsControl',
|
||||||
multi: true,
|
multi: true,
|
||||||
label: t('Metrics'),
|
label: t('Metrics'),
|
||||||
validators: [v.nonEmpty],
|
validators: [v.nonEmpty],
|
||||||
valueKey: 'metric_name',
|
|
||||||
optionRenderer: m => <MetricOption metric={m} showType />,
|
|
||||||
valueRenderer: m => <MetricOption metric={m} />,
|
|
||||||
default: (c) => {
|
default: (c) => {
|
||||||
const metric = mainMetric(c.options);
|
const metric = mainMetric(c.options);
|
||||||
return metric ? [metric] : null;
|
return metric ? [metric] : null;
|
||||||
},
|
},
|
||||||
mapStateToProps: state => ({
|
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'),
|
description: t('One or many metrics to display'),
|
||||||
},
|
},
|
||||||
|
@ -219,17 +218,16 @@ export const controls = {
|
||||||
},
|
},
|
||||||
|
|
||||||
metric: {
|
metric: {
|
||||||
type: 'SelectControl',
|
type: 'MetricsControl',
|
||||||
|
multi: false,
|
||||||
label: t('Metric'),
|
label: t('Metric'),
|
||||||
clearable: false,
|
clearable: false,
|
||||||
description: t('Choose the metric'),
|
|
||||||
validators: [v.nonEmpty],
|
validators: [v.nonEmpty],
|
||||||
optionRenderer: m => <MetricOption metric={m} showType />,
|
|
||||||
valueRenderer: m => <MetricOption metric={m} />,
|
|
||||||
default: c => mainMetric(c.options),
|
default: c => mainMetric(c.options),
|
||||||
valueKey: 'metric_name',
|
|
||||||
mapStateToProps: state => ({
|
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,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -139,7 +139,8 @@ export const visTypes = {
|
||||||
label: t('Query'),
|
label: t('Query'),
|
||||||
expanded: true,
|
expanded: true,
|
||||||
controlSetRows: [
|
controlSetRows: [
|
||||||
['metrics', 'groupby'],
|
['metrics'],
|
||||||
|
['groupby'],
|
||||||
['limit'],
|
['limit'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -96,7 +96,7 @@
|
||||||
"react-map-gl": "^3.0.4",
|
"react-map-gl": "^3.0.4",
|
||||||
"react-redux": "^5.0.2",
|
"react-redux": "^5.0.2",
|
||||||
"react-resizable": "^1.3.3",
|
"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-select-fast-filter-options": "^0.2.1",
|
||||||
"react-sortable-hoc": "^0.6.7",
|
"react-sortable-hoc": "^0.6.7",
|
||||||
"react-split-pane": "^0.1.66",
|
"react-split-pane": "^0.1.66",
|
||||||
|
@ -127,7 +127,7 @@
|
||||||
"clean-webpack-plugin": "^0.1.16",
|
"clean-webpack-plugin": "^0.1.16",
|
||||||
"css-loader": "^0.28.0",
|
"css-loader": "^0.28.0",
|
||||||
"enzyme": "^2.0.0",
|
"enzyme": "^2.0.0",
|
||||||
"eslint": "^3.19.0",
|
"eslint": "^4.19.0",
|
||||||
"eslint-config-airbnb": "^15.0.1",
|
"eslint-config-airbnb": "^15.0.1",
|
||||||
"eslint-plugin-import": "^2.2.0",
|
"eslint-plugin-import": "^2.2.0",
|
||||||
"eslint-plugin-jsx-a11y": "^5.1.1",
|
"eslint-plugin-jsx-a11y": "^5.1.1",
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -449,3 +449,9 @@ g.annotation-container {
|
||||||
color: @brand-primary;
|
color: @brand-primary;
|
||||||
border-color: @brand-primary;
|
border-color: @brand-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-edit-popover-label-input {
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 30px;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
|
@ -897,13 +897,16 @@ class DruidDatasource(Model, BaseDatasource):
|
||||||
def metrics_and_post_aggs(metrics, metrics_dict):
|
def metrics_and_post_aggs(metrics, metrics_dict):
|
||||||
# Separate metrics into those that are aggregations
|
# Separate metrics into those that are aggregations
|
||||||
# and those that are post aggregations
|
# and those that are post aggregations
|
||||||
agg_names = set()
|
saved_agg_names = set()
|
||||||
|
adhoc_agg_configs = []
|
||||||
postagg_names = []
|
postagg_names = []
|
||||||
for metric_name in metrics:
|
for metric in metrics:
|
||||||
if metrics_dict[metric_name].metric_type != 'postagg':
|
if utils.is_adhoc_metric(metric):
|
||||||
agg_names.add(metric_name)
|
adhoc_agg_configs.append(metric)
|
||||||
|
elif metrics_dict[metric].metric_type != 'postagg':
|
||||||
|
saved_agg_names.add(metric)
|
||||||
else:
|
else:
|
||||||
postagg_names.append(metric_name)
|
postagg_names.append(metric)
|
||||||
# Create the post aggregations, maintain order since postaggs
|
# Create the post aggregations, maintain order since postaggs
|
||||||
# may depend on previous ones
|
# may depend on previous ones
|
||||||
post_aggs = OrderedDict()
|
post_aggs = OrderedDict()
|
||||||
|
@ -912,8 +915,8 @@ class DruidDatasource(Model, BaseDatasource):
|
||||||
postagg = metrics_dict[postagg_name]
|
postagg = metrics_dict[postagg_name]
|
||||||
visited_postaggs.add(postagg_name)
|
visited_postaggs.add(postagg_name)
|
||||||
DruidDatasource.resolve_postagg(
|
DruidDatasource.resolve_postagg(
|
||||||
postagg, post_aggs, agg_names, visited_postaggs, metrics_dict)
|
postagg, post_aggs, saved_agg_names, visited_postaggs, metrics_dict)
|
||||||
return list(agg_names), post_aggs
|
return list(saved_agg_names), adhoc_agg_configs, post_aggs
|
||||||
|
|
||||||
def values_for_column(self,
|
def values_for_column(self,
|
||||||
column_name,
|
column_name,
|
||||||
|
@ -968,11 +971,29 @@ class DruidDatasource(Model, BaseDatasource):
|
||||||
ret = Filter(type='and', fields=[ff, dim_filter])
|
ret = Filter(type='and', fields=[ff, dim_filter])
|
||||||
return ret
|
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()
|
aggregations = OrderedDict()
|
||||||
for m in self.metrics:
|
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
|
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
|
return aggregations
|
||||||
|
|
||||||
def check_restricted_metrics(self, aggregations):
|
def check_restricted_metrics(self, aggregations):
|
||||||
|
@ -1066,11 +1087,11 @@ class DruidDatasource(Model, BaseDatasource):
|
||||||
metrics_dict = {m.metric_name: m for m in self.metrics}
|
metrics_dict = {m.metric_name: m for m in self.metrics}
|
||||||
columns_dict = {c.column_name: c for c in self.columns}
|
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,
|
||||||
metrics_dict)
|
metrics_dict)
|
||||||
|
|
||||||
aggregations = self.get_aggregations(all_metrics)
|
aggregations = self.get_aggregations(saved_metrics, adhoc_metrics)
|
||||||
self.check_restricted_metrics(aggregations)
|
self.check_restricted_metrics(aggregations)
|
||||||
|
|
||||||
# the dimensions list with dimensionSpecs expanded
|
# the dimensions list with dimensionSpecs expanded
|
||||||
|
@ -1246,6 +1267,7 @@ class DruidDatasource(Model, BaseDatasource):
|
||||||
cols += query_obj.get('columns') or []
|
cols += query_obj.get('columns') or []
|
||||||
cols += query_obj.get('metrics') or []
|
cols += query_obj.get('metrics') or []
|
||||||
|
|
||||||
|
cols = utils.get_metric_names(cols)
|
||||||
cols = [col for col in cols if col in df.columns]
|
cols = [col for col in cols if col in df.columns]
|
||||||
df = df[cols]
|
df = df[cols]
|
||||||
|
|
||||||
|
|
|
@ -278,6 +278,15 @@ class SqlaTable(Model, BaseDatasource):
|
||||||
export_parent = 'database'
|
export_parent = 'database'
|
||||||
export_children = ['metrics', 'columns']
|
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):
|
def __repr__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@ -436,6 +445,12 @@ class SqlaTable(Model, BaseDatasource):
|
||||||
return TextAsFrom(sa.text(from_sql), []).alias('expr_qry')
|
return TextAsFrom(sa.text(from_sql), []).alias('expr_qry')
|
||||||
return self.get_sqla_table()
|
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
|
def get_sqla_query( # sqla
|
||||||
self,
|
self,
|
||||||
groupby, metrics,
|
groupby, metrics,
|
||||||
|
@ -484,10 +499,14 @@ class SqlaTable(Model, BaseDatasource):
|
||||||
'and is required by this type of chart'))
|
'and is required by this type of chart'))
|
||||||
if not groupby and not metrics and not columns:
|
if not groupby and not metrics and not columns:
|
||||||
raise Exception(_('Empty query?'))
|
raise Exception(_('Empty query?'))
|
||||||
|
metrics_exprs = []
|
||||||
for m in metrics:
|
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)))
|
raise Exception(_("Metric '{}' is not valid".format(m)))
|
||||||
metrics_exprs = [metrics_dict.get(m).sqla_col for m in metrics]
|
|
||||||
if metrics_exprs:
|
if metrics_exprs:
|
||||||
main_metric_expr = metrics_exprs[0]
|
main_metric_expr = metrics_exprs[0]
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -832,8 +832,7 @@ def get_or_create_main_db():
|
||||||
dbobj = (
|
dbobj = (
|
||||||
db.session.query(models.Database)
|
db.session.query(models.Database)
|
||||||
.filter_by(database_name='main')
|
.filter_by(database_name='main')
|
||||||
.first()
|
.first())
|
||||||
)
|
|
||||||
if not dbobj:
|
if not dbobj:
|
||||||
dbobj = models.Database(database_name='main')
|
dbobj = models.Database(database_name='main')
|
||||||
dbobj.set_sqlalchemy_uri(conf.get('SQLALCHEMY_DATABASE_URI'))
|
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.add(dbobj)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return dbobj
|
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]
|
||||||
|
|
|
@ -64,7 +64,14 @@ class BaseViz(object):
|
||||||
self.query = ''
|
self.query = ''
|
||||||
self.token = self.form_data.get(
|
self.token = self.form_data.get(
|
||||||
'token', 'token_' + uuid.uuid4().hex[:8])
|
'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.groupby = self.form_data.get('groupby') or []
|
||||||
self.time_shift = timedelta()
|
self.time_shift = timedelta()
|
||||||
|
|
||||||
|
@ -1058,12 +1065,12 @@ class NVD3TimeSeriesViz(NVD3Viz):
|
||||||
df = df.pivot_table(
|
df = df.pivot_table(
|
||||||
index=DTTM_ALIAS,
|
index=DTTM_ALIAS,
|
||||||
columns=fd.get('groupby'),
|
columns=fd.get('groupby'),
|
||||||
values=fd.get('metrics'))
|
values=utils.get_metric_names(fd.get('metrics')))
|
||||||
else:
|
else:
|
||||||
df = df.pivot_table(
|
df = df.pivot_table(
|
||||||
index=DTTM_ALIAS,
|
index=DTTM_ALIAS,
|
||||||
columns=fd.get('groupby'),
|
columns=fd.get('groupby'),
|
||||||
values=fd.get('metrics'),
|
values=utils.get_metric_names(fd.get('metrics')),
|
||||||
fill_value=0,
|
fill_value=0,
|
||||||
aggfunc=sum)
|
aggfunc=sum)
|
||||||
|
|
||||||
|
|
|
@ -180,6 +180,51 @@ class DruidFuncTestCase(unittest.TestCase):
|
||||||
self.assertIn('post_aggregations', called_args)
|
self.assertIn('post_aggregations', called_args)
|
||||||
# restore functions
|
# 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):
|
def test_run_query_single_groupby(self):
|
||||||
client = Mock()
|
client = Mock()
|
||||||
from_dttm = Mock()
|
from_dttm = Mock()
|
||||||
|
@ -467,7 +512,7 @@ class DruidFuncTestCase(unittest.TestCase):
|
||||||
depends_on('I', ['H', 'K'])
|
depends_on('I', ['H', 'K'])
|
||||||
depends_on('J', 'K')
|
depends_on('J', 'K')
|
||||||
depends_on('K', ['m8', 'm9'])
|
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)
|
metrics, metrics_dict)
|
||||||
expected_metrics = set(all_metrics)
|
expected_metrics = set(all_metrics)
|
||||||
self.assertEqual(9, len(all_metrics))
|
self.assertEqual(9, len(all_metrics))
|
||||||
|
@ -541,25 +586,80 @@ class DruidFuncTestCase(unittest.TestCase):
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
adhoc_metric = {
|
||||||
|
'column': {'type': 'DOUBLE', 'column_name': 'value'},
|
||||||
|
'aggregate': 'SUM',
|
||||||
|
'label': 'My Adhoc Metric',
|
||||||
|
}
|
||||||
|
|
||||||
metrics = ['some_sum']
|
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)
|
metrics, metrics_dict)
|
||||||
|
|
||||||
assert all_metrics == ['some_sum']
|
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 saved_metrics == ['some_sum']
|
||||||
|
assert adhoc_metrics == [adhoc_metric]
|
||||||
assert post_aggs == {}
|
assert post_aggs == {}
|
||||||
|
|
||||||
metrics = ['quantile_p95']
|
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)
|
metrics, metrics_dict)
|
||||||
|
|
||||||
result_postaggs = set(['quantile_p95'])
|
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
|
assert set(post_aggs.keys()) == result_postaggs
|
||||||
|
|
||||||
metrics = ['aCustomPostAgg']
|
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)
|
metrics, metrics_dict)
|
||||||
|
|
||||||
result_postaggs = set(['aCustomPostAgg'])
|
result_postaggs = set(['aCustomPostAgg'])
|
||||||
assert all_metrics == ['aCustomMetric']
|
assert saved_metrics == ['aCustomMetric']
|
||||||
|
assert adhoc_metrics == []
|
||||||
assert set(post_aggs.keys()) == result_postaggs
|
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')
|
||||||
|
|
Loading…
Reference in New Issue