diff --git a/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js b/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js new file mode 100644 index 0000000000..0cf9e58e1f --- /dev/null +++ b/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js @@ -0,0 +1,136 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../src/explore/AdhocFilter'; + +describe('AdhocFilter', () => { + it('sets filterOptionName in constructor', () => { + const adhocFilter = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, + }); + expect(adhocFilter.filterOptionName.length).to.be.above(10); + expect(adhocFilter).to.deep.equal({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, + filterOptionName: adhocFilter.filterOptionName, + sqlExpression: null, + fromFormData: false, + }); + }); + + it('can create altered duplicates', () => { + const adhocFilter1 = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, + }); + const adhocFilter2 = adhocFilter1.duplicateWith({ operator: '<' }); + + expect(adhocFilter1.subject).to.equal(adhocFilter2.subject); + expect(adhocFilter1.comparator).to.equal(adhocFilter2.comparator); + expect(adhocFilter1.clause).to.equal(adhocFilter2.clause); + expect(adhocFilter1.expressionType).to.equal(adhocFilter2.expressionType); + + expect(adhocFilter1.operator).to.equal('>'); + expect(adhocFilter2.operator).to.equal('<'); + }); + + it('can verify equality', () => { + const adhocFilter1 = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, + }); + const adhocFilter2 = adhocFilter1.duplicateWith({}); + + // eslint-disable-next-line no-unused-expressions + expect(adhocFilter1.equals(adhocFilter2)).to.be.true; + // eslint-disable-next-line no-unused-expressions + expect(adhocFilter1 === adhocFilter2).to.be.false; + }); + + it('can verify inequality', () => { + const adhocFilter1 = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, + }); + const adhocFilter2 = adhocFilter1.duplicateWith({ operator: '<' }); + + // eslint-disable-next-line no-unused-expressions + expect(adhocFilter1.equals(adhocFilter2)).to.be.false; + + const adhocFilter3 = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SQL, + sqlExpression: 'value > 10', + clause: CLAUSES.WHERE, + }); + const adhocFilter4 = adhocFilter3.duplicateWith({ sqlExpression: 'value = 5' }); + + // eslint-disable-next-line no-unused-expressions + expect(adhocFilter3.equals(adhocFilter4)).to.be.false; + }); + + it('can determine if it is valid', () => { + const adhocFilter1 = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, + }); + // eslint-disable-next-line no-unused-expressions + expect(adhocFilter1.isValid()).to.be.true; + + const adhocFilter2 = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: null, + clause: CLAUSES.WHERE, + }); + // eslint-disable-next-line no-unused-expressions + expect(adhocFilter2.isValid()).to.be.false; + + const adhocFilter3 = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SQL, + sqlExpression: 'some expression', + clause: null, + }); + // eslint-disable-next-line no-unused-expressions + expect(adhocFilter3.isValid()).to.be.false; + }); + + it('can translate from simple expressions to sql expressions', () => { + const adhocFilter1 = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '==', + comparator: '10', + clause: CLAUSES.WHERE, + }); + expect(adhocFilter1.translateToSql()).to.equal('value = 10'); + + const adhocFilter2 = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'SUM(value)', + operator: '!=', + comparator: '5', + clause: CLAUSES.HAVING, + }); + expect(adhocFilter2.translateToSql()).to.equal('SUM(value) <> 5'); + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx new file mode 100644 index 0000000000..4be8a2eba3 --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx @@ -0,0 +1,189 @@ +/* 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 AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter'; +import AdhocFilterControl from '../../../../src/explore/components/controls/AdhocFilterControl'; +import AdhocMetric from '../../../../src/explore/AdhocMetric'; +import { AGGREGATES, OPERATORS } from '../../../../src/explore/constants'; +import OnPasteSelect from '../../../../src/components/OnPasteSelect'; + +const simpleAdhocFilter = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, +}); + +const sumValueAdhocMetric = new AdhocMetric({ + expressionType: EXPRESSION_TYPES.SIMPLE, + column: { type: 'VARCHAR(255)', column_name: 'source' }, + aggregate: AGGREGATES.SUM, +}); + +const savedMetric = { metric_name: 'sum__value', expression: 'SUM(value)' }; + +const columns = [ + { type: 'VARCHAR(255)', column_name: 'source' }, + { type: 'VARCHAR(255)', column_name: 'target' }, + { type: 'DOUBLE', column_name: 'value' }, +]; + +const legacyFilter = { col: 'value', op: '>', val: '5' }; +const legacyHavingFilter = { col: 'SUM(value)', op: '>', val: '10' }; +const whereFilterText = 'target in (\'alpha\')'; +const havingFilterText = 'SUM(value) < 20'; + +const formData = { + filters: [legacyFilter], + having: havingFilterText, + having_filters: [legacyHavingFilter], + metric: undefined, + metrics: [sumValueAdhocMetric, savedMetric.saved_metric_name], + where: whereFilterText, +}; + +function setup(overrides) { + const onChange = sinon.spy(); + const props = { + onChange, + value: [simpleAdhocFilter], + datasource: { type: 'table' }, + columns, + savedMetrics: [savedMetric], + formData, + ...overrides, + }; + const wrapper = shallow(); + return { wrapper, onChange }; +} + +describe('AdhocFilterControl', () => { + it('renders an onPasteSelect', () => { + const { wrapper } = setup(); + expect(wrapper.find(OnPasteSelect)).to.have.lengthOf(1); + }); + + it('will translate legacy filters into adhoc filters if no adhoc filters are present', () => { + const { wrapper } = setup({ value: undefined }); + expect(wrapper.state('values')).to.have.lengthOf(4); + expect(wrapper.state('values')[0].equals(( + new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '5', + clause: CLAUSES.WHERE, + }) + ))).to.be.true; + expect(wrapper.state('values')[1].equals(( + new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'SUM(value)', + operator: '>', + comparator: '10', + clause: CLAUSES.HAVING, + }) + ))).to.be.true; + expect(wrapper.state('values')[2].equals(( + new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SQL, + sqlExpression: 'target in (\'alpha\')', + clause: CLAUSES.WHERE, + }) + ))).to.be.true; + expect(wrapper.state('values')[3].equals(( + new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SQL, + sqlExpression: 'SUM(value) < 20', + clause: CLAUSES.HAVING, + }) + ))).to.be.true; + }); + + it('will ignore legacy filters if adhoc filters are present', () => { + const { wrapper } = setup(); + expect(wrapper.state('values')).to.have.lengthOf(1); + expect(wrapper.state('values')[0]).to.equal(simpleAdhocFilter); + }); + + it('handles saved metrics being selected to filter on', () => { + const { wrapper, onChange } = setup({ value: [] }); + const select = wrapper.find(OnPasteSelect); + select.simulate('change', [{ saved_metric_name: 'sum__value' }]); + + const adhocFilter = onChange.lastCall.args[0][0]; + expect(adhocFilter instanceof AdhocFilter).to.be.true; + expect(adhocFilter.equals(( + new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SQL, + subject: savedMetric.expression, + operator: OPERATORS['>'], + comparator: 0, + clause: CLAUSES.HAVING, + }) + ))).to.be.true; + }); + + it('handles adhoc metrics being selected to filter on', () => { + const { wrapper, onChange } = setup({ value: [] }); + const select = wrapper.find(OnPasteSelect); + select.simulate('change', [sumValueAdhocMetric]); + + const adhocFilter = onChange.lastCall.args[0][0]; + expect(adhocFilter instanceof AdhocFilter).to.be.true; + expect(adhocFilter.equals(( + new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SQL, + subject: sumValueAdhocMetric.label, + operator: OPERATORS['>'], + comparator: 0, + clause: CLAUSES.HAVING, + }) + ))).to.be.true; + }); + + it('handles columns being selected to filter on', () => { + const { wrapper, onChange } = setup({ value: [] }); + const select = wrapper.find(OnPasteSelect); + select.simulate('change', [columns[0]]); + + const adhocFilter = onChange.lastCall.args[0][0]; + expect(adhocFilter instanceof AdhocFilter).to.be.true; + expect(adhocFilter.equals(( + new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: columns[0].column_name, + operator: OPERATORS['=='], + comparator: '', + clause: CLAUSES.WHERE, + }) + ))).to.be.true; + }); + + it('persists existing filters even when new filters are added', () => { + const { wrapper, onChange } = setup(); + const select = wrapper.find(OnPasteSelect); + select.simulate('change', [simpleAdhocFilter, columns[0]]); + + const existingAdhocFilter = onChange.lastCall.args[0][0]; + expect(existingAdhocFilter instanceof AdhocFilter).to.be.true; + expect(existingAdhocFilter.equals(simpleAdhocFilter)).to.be.true; + + const newAdhocFilter = onChange.lastCall.args[0][1]; + expect(newAdhocFilter instanceof AdhocFilter).to.be.true; + expect(newAdhocFilter.equals(( + new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: columns[0].column_name, + operator: OPERATORS['=='], + comparator: '', + clause: CLAUSES.WHERE, + }) + ))).to.be.true; + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx new file mode 100644 index 0000000000..005b287626 --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx @@ -0,0 +1,122 @@ +/* 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 { FormGroup } from 'react-bootstrap'; + +import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter'; +import AdhocMetric from '../../../../src/explore/AdhocMetric'; +import AdhocFilterEditPopoverSimpleTabContent from '../../../../src/explore/components/AdhocFilterEditPopoverSimpleTabContent'; +import { AGGREGATES } from '../../../../src/explore/constants'; + +const simpleAdhocFilter = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, +}); + +const simpleMultiAdhocFilter = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: 'in', + comparator: ['10'], + clause: CLAUSES.WHERE, +}); + +const sumValueAdhocMetric = new AdhocMetric({ + expressionType: EXPRESSION_TYPES.SIMPLE, + column: { type: 'VARCHAR(255)', column_name: 'source' }, + aggregate: AGGREGATES.SUM, +}); + +const options = [ + { type: 'VARCHAR(255)', column_name: 'source' }, + { type: 'VARCHAR(255)', column_name: 'target' }, + { type: 'DOUBLE', column_name: 'value' }, + { saved_metric_name: 'my_custom_metric' }, + sumValueAdhocMetric, +]; + +function setup(overrides) { + const onChange = sinon.spy(); + const props = { + adhocFilter: simpleAdhocFilter, + onChange, + options, + datasource: {}, + ...overrides, + }; + const wrapper = shallow(); + return { wrapper, onChange }; +} + +describe('AdhocFilterEditPopoverSimpleTabContent', () => { + it('renders the simple tab form', () => { + const { wrapper } = setup(); + expect(wrapper.find(FormGroup)).to.have.lengthOf(3); + }); + + it('passes the new adhocFilter to onChange after onSubjectChange', () => { + const { wrapper, onChange } = setup(); + wrapper.instance().onSubjectChange({ type: 'VARCHAR(255)', column_name: 'source' }); + expect(onChange.calledOnce).to.be.true; + expect(onChange.lastCall.args[0].equals(( + simpleAdhocFilter.duplicateWith({ subject: 'source' }) + ))).to.be.true; + }); + + it('may alter the clause in onSubjectChange if the old clause is not appropriate', () => { + const { wrapper, onChange } = setup(); + wrapper.instance().onSubjectChange(sumValueAdhocMetric); + expect(onChange.calledOnce).to.be.true; + expect(onChange.lastCall.args[0].equals(( + simpleAdhocFilter.duplicateWith({ + subject: sumValueAdhocMetric.label, + clause: CLAUSES.HAVING, + }) + ))).to.be.true; + }); + + it('will convert from individual comparator to array if the operator changes to multi', () => { + const { wrapper, onChange } = setup(); + wrapper.instance().onOperatorChange({ operator: 'in' }); + expect(onChange.calledOnce).to.be.true; + expect(onChange.lastCall.args[0].comparator).to.have.lengthOf(1); + expect(onChange.lastCall.args[0].comparator[0]).to.equal('10'); + expect(onChange.lastCall.args[0].operator).to.equal('in'); + }); + + it('will convert from array to individual comparators if the operator changes from multi', () => { + const { wrapper, onChange } = setup({ adhocFilter: simpleMultiAdhocFilter }); + wrapper.instance().onOperatorChange({ operator: '<' }); + expect(onChange.calledOnce).to.be.true; + expect(onChange.lastCall.args[0].equals(( + simpleAdhocFilter.duplicateWith({ operator: '<', comparator: '10' }) + ))).to.be.true; + }); + + it('passes the new adhocFilter to onChange after onComparatorChange', () => { + const { wrapper, onChange } = setup(); + wrapper.instance().onComparatorChange('20'); + expect(onChange.calledOnce).to.be.true; + expect(onChange.lastCall.args[0].equals(( + simpleAdhocFilter.duplicateWith({ comparator: '20' }) + ))).to.be.true; + }); + + it('will filter operators for table datasources', () => { + const { wrapper } = setup({ datasource: { type: 'table' } }); + expect(wrapper.instance().isOperatorRelevant('regex')).to.be.false; + expect(wrapper.instance().isOperatorRelevant('like')).to.be.true; + }); + + it('will filter operators for druid datasources', () => { + const { wrapper } = setup({ datasource: { type: 'druid' } }); + expect(wrapper.instance().isOperatorRelevant('regex')).to.be.true; + expect(wrapper.instance().isOperatorRelevant('like')).to.be.false; + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSqlTabContent_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSqlTabContent_spec.jsx new file mode 100644 index 0000000000..a1cdb23247 --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSqlTabContent_spec.jsx @@ -0,0 +1,54 @@ +/* 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 { FormGroup } from 'react-bootstrap'; + +import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter'; +import AdhocFilterEditPopoverSqlTabContent from '../../../../src/explore/components/AdhocFilterEditPopoverSqlTabContent'; + +const sqlAdhocFilter = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SQL, + sqlExpression: 'value > 10', + clause: CLAUSES.WHERE, +}); + +function setup(overrides) { + const onChange = sinon.spy(); + const props = { + adhocFilter: sqlAdhocFilter, + onChange, + options: [], + height: 100, + ...overrides, + }; + const wrapper = shallow(); + return { wrapper, onChange }; +} + +describe('AdhocFilterEditPopoverSqlTabContent', () => { + it('renders the sql tab form', () => { + const { wrapper } = setup(); + expect(wrapper.find(FormGroup)).to.have.lengthOf(2); + }); + + it('passes the new clause to onChange after onSqlExpressionClauseChange', () => { + const { wrapper, onChange } = setup(); + wrapper.instance().onSqlExpressionClauseChange(CLAUSES.HAVING); + expect(onChange.calledOnce).to.be.true; + expect(onChange.lastCall.args[0].equals(( + sqlAdhocFilter.duplicateWith({ clause: CLAUSES.HAVING }) + ))).to.be.true; + }); + + it('passes the new query to onChange after onSqlExpressionChange', () => { + const { wrapper, onChange } = setup(); + wrapper.instance().onSqlExpressionChange('value < 5'); + expect(onChange.calledOnce).to.be.true; + expect(onChange.lastCall.args[0].equals(( + sqlAdhocFilter.duplicateWith({ sqlExpression: 'value < 5' }) + ))).to.be.true; + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopover_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopover_spec.jsx new file mode 100644 index 0000000000..3b062ed272 --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopover_spec.jsx @@ -0,0 +1,112 @@ +/* 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, Popover, Tab, Tabs } from 'react-bootstrap'; + +import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter'; +import AdhocMetric from '../../../../src/explore/AdhocMetric'; +import AdhocFilterEditPopover from '../../../../src/explore/components/AdhocFilterEditPopover'; +import AdhocFilterEditPopoverSimpleTabContent from '../../../../src/explore/components/AdhocFilterEditPopoverSimpleTabContent'; +import AdhocFilterEditPopoverSqlTabContent from '../../../../src/explore/components/AdhocFilterEditPopoverSqlTabContent'; +import { AGGREGATES } from '../../../../src/explore/constants'; + +const simpleAdhocFilter = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, +}); + +const sqlAdhocFilter = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SQL, + sqlExpression: 'value > 10', + clause: CLAUSES.WHERE, +}); + +const sumValueAdhocMetric = new AdhocMetric({ + expressionType: EXPRESSION_TYPES.SIMPLE, + column: { type: 'VARCHAR(255)', column_name: 'source' }, + aggregate: AGGREGATES.SUM, +}); + +const options = [ + { type: 'VARCHAR(255)', column_name: 'source' }, + { type: 'VARCHAR(255)', column_name: 'target' }, + { type: 'DOUBLE', column_name: 'value' }, + { saved_metric_name: 'my_custom_metric' }, + sumValueAdhocMetric, +]; + +function setup(overrides) { + const onChange = sinon.spy(); + const onClose = sinon.spy(); + const onResize = sinon.spy(); + const props = { + adhocFilter: simpleAdhocFilter, + onChange, + onClose, + onResize, + options, + datasource: {}, + ...overrides, + }; + const wrapper = shallow(); + return { wrapper, onChange, onClose, onResize }; +} + +describe('AdhocFilterEditPopover', () => { + it('renders simple tab content by default', () => { + const { wrapper } = setup(); + expect(wrapper.find(Popover)).to.have.lengthOf(1); + expect(wrapper.find(Tabs)).to.have.lengthOf(1); + expect(wrapper.find(Tab)).to.have.lengthOf(2); + expect(wrapper.find(Button)).to.have.lengthOf(2); + expect(wrapper.find(AdhocFilterEditPopoverSimpleTabContent)).to.have.lengthOf(1); + }); + + it('renders sql tab content when the adhoc filter expressionType is sql', () => { + const { wrapper } = setup({ adhocFilter: sqlAdhocFilter }); + expect(wrapper.find(Popover)).to.have.lengthOf(1); + expect(wrapper.find(Tabs)).to.have.lengthOf(1); + expect(wrapper.find(Tab)).to.have.lengthOf(2); + expect(wrapper.find(Button)).to.have.lengthOf(2); + expect(wrapper.find(AdhocFilterEditPopoverSqlTabContent)).to.have.lengthOf(1); + }); + + it('overwrites the adhocFilter in state with onAdhocFilterChange', () => { + const { wrapper } = setup(); + wrapper.instance().onAdhocFilterChange(sqlAdhocFilter); + expect(wrapper.state('adhocFilter')).to.deep.equal(sqlAdhocFilter); + }); + + it('prevents saving if the filter is invalid', () => { + const { wrapper } = setup(); + expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(0); + wrapper.instance().onAdhocFilterChange(simpleAdhocFilter.duplicateWith({ operator: null })); + expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(1); + wrapper.instance().onAdhocFilterChange(sqlAdhocFilter); + expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(0); + }); + + it('highlights save if changes are present', () => { + const { wrapper } = setup(); + expect(wrapper.find(Button).find({ bsStyle: 'primary' })).to.have.lengthOf(0); + wrapper.instance().onAdhocFilterChange(sqlAdhocFilter); + expect(wrapper.find(Button).find({ bsStyle: 'primary' })).to.have.lengthOf(1); + }); + + it('will initiate a drag when clicked', () => { + const { wrapper } = setup(); + wrapper.instance().onDragDown = sinon.spy(); + wrapper.instance().forceUpdate(); + + expect(wrapper.find('i.glyphicon-resize-full')).to.have.lengthOf(1); + expect(wrapper.instance().onDragDown.calledOnce).to.be.false; + wrapper.find('i.glyphicon-resize-full').simulate('mouseDown'); + expect(wrapper.instance().onDragDown.calledOnce).to.be.true; + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterOption_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterOption_spec.jsx new file mode 100644 index 0000000000..673b854e5c --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterOption_spec.jsx @@ -0,0 +1,39 @@ +/* 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 AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter'; +import AdhocFilterOption from '../../../../src/explore/components/AdhocFilterOption'; + +const simpleAdhocFilter = new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: 'value', + operator: '>', + comparator: '10', + clause: CLAUSES.WHERE, +}); + +function setup(overrides) { + const onFilterEdit = sinon.spy(); + const props = { + adhocFilter: simpleAdhocFilter, + onFilterEdit, + options: [], + datasource: {}, + ...overrides, + }; + const wrapper = shallow(); + return { wrapper }; +} + +describe('AdhocFilterOption', () => { + 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/AdhocMetricStaticOption_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocMetricStaticOption_spec.jsx new file mode 100644 index 0000000000..54ff78e66f --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/AdhocMetricStaticOption_spec.jsx @@ -0,0 +1,22 @@ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { shallow } from 'enzyme'; + +import AdhocMetricStaticOption from '../../../../src/explore/components/AdhocMetricStaticOption'; +import AdhocMetric, { EXPRESSION_TYPES } from '../../../../src/explore/AdhocMetric'; +import { AGGREGATES } from '../../../../src/explore/constants'; + +const sumValueAdhocMetric = new AdhocMetric({ + expressionType: EXPRESSION_TYPES.SIMPLE, + column: { type: 'VARCHAR(255)', column_name: 'source' }, + aggregate: AGGREGATES.SUM, +}); + +describe('AdhocMetricStaticOption', () => { + it('renders the adhoc metrics label', () => { + const wrapper = shallow(); + expect(wrapper.text()).to.equal('SUM(source)'); + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/FilterDefinitionOption_spec.jsx b/superset/assets/spec/javascripts/explore/components/FilterDefinitionOption_spec.jsx new file mode 100644 index 0000000000..05e02b92a4 --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/FilterDefinitionOption_spec.jsx @@ -0,0 +1,36 @@ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { shallow } from 'enzyme'; + +import FilterDefinitionOption from '../../../../src/explore/components/FilterDefinitionOption'; +import ColumnOption from '../../../../src/components/ColumnOption'; +import AdhocMetricStaticOption from '../../../../src/explore/components/AdhocMetricStaticOption'; +import AdhocMetric, { EXPRESSION_TYPES } from '../../../../src/explore/AdhocMetric'; +import { AGGREGATES } from '../../../../src/explore/constants'; + +const sumValueAdhocMetric = new AdhocMetric({ + expressionType: EXPRESSION_TYPES.SIMPLE, + column: { type: 'VARCHAR(255)', column_name: 'source' }, + aggregate: AGGREGATES.SUM, +}); + +describe('FilterDefinitionOption', () => { + it('renders a ColumnOption given a column', () => { + const wrapper = shallow(); + expect(wrapper.find(ColumnOption)).to.have.lengthOf(1); + }); + + it('renders a AdhocMetricStaticOption given an adhoc metric', () => { + const wrapper = shallow(); + expect(wrapper.find(AdhocMetricStaticOption)).to.have.lengthOf(1); + }); + + it('renders the metric name given a saved metric', () => { + const wrapper = shallow(( + + )); + expect(wrapper.text()).to.equal('my_custom_metric'); + }); +}); diff --git a/superset/assets/src/explore/AdhocFilter.js b/superset/assets/src/explore/AdhocFilter.js new file mode 100644 index 0000000000..0c84ef55a9 --- /dev/null +++ b/superset/assets/src/explore/AdhocFilter.js @@ -0,0 +1,102 @@ +import { MULTI_OPERATORS } from './constants'; + +export const EXPRESSION_TYPES = { + SIMPLE: 'SIMPLE', + SQL: 'SQL', +}; + +export const CLAUSES = { + HAVING: 'HAVING', + WHERE: 'WHERE', +}; + +const OPERATORS_TO_SQL = { + '==': '=', + '!=': '<>', + '>': '>', + '<': '<', + '>=': '>=', + '<=': '<=', + in: 'in', + 'not in': 'not in', + like: 'like', +}; + +function translateToSql(adhocMetric, { useSimple } = {}) { + if (adhocMetric.expressionType === EXPRESSION_TYPES.SIMPLE || useSimple) { + const isMulti = MULTI_OPERATORS.indexOf(adhocMetric.operator) >= 0; + const subject = adhocMetric.subject; + const operator = OPERATORS_TO_SQL[adhocMetric.operator]; + const comparator = isMulti ? adhocMetric.comparator.join("','") : adhocMetric.comparator; + return `${subject} ${operator} ${isMulti ? '(\'' : ''}${comparator}${isMulti ? '\')' : ''}`; + } else if (adhocMetric.expressionType === EXPRESSION_TYPES.SQL) { + return adhocMetric.sqlExpression; + } + return ''; +} + +export default class AdhocFilter { + constructor(adhocFilter) { + this.expressionType = adhocFilter.expressionType || EXPRESSION_TYPES.SIMPLE; + if (this.expressionType === EXPRESSION_TYPES.SIMPLE) { + this.subject = adhocFilter.subject; + this.operator = adhocFilter.operator; + this.comparator = adhocFilter.comparator; + this.clause = adhocFilter.clause; + this.sqlExpression = null; + } else if (this.expressionType === EXPRESSION_TYPES.SQL) { + this.sqlExpression = adhocFilter.sqlExpression || + translateToSql(adhocFilter, { useSimple: true }); + this.clause = adhocFilter.clause; + this.subject = null; + this.operator = null; + this.comparator = null; + } + this.fromFormData = !!adhocFilter.filterOptionName; + + this.filterOptionName = adhocFilter.filterOptionName || + `filter_${Math.random().toString(36).substring(2, 15)}_${Math.random().toString(36).substring(2, 15)}`; + } + + duplicateWith(nextFields) { + return new AdhocFilter({ + ...this, + expressionType: this.expressionType, + subject: this.subject, + operator: this.operator, + clause: this.clause, + sqlExpression: this.sqlExpression, + fromFormData: this.fromFormData, + filterOptionName: this.filterOptionName, + ...nextFields, + }); + } + + equals(adhocFilter) { + return adhocFilter.expressionType === this.expressionType && + adhocFilter.sqlExpression === this.sqlExpression && + adhocFilter.operator === this.operator && + adhocFilter.comparator === this.comparator && + adhocFilter.subject === this.subject; + } + + isValid() { + if (this.expressionType === EXPRESSION_TYPES.SIMPLE) { + return !!(this.operator && this.subject && this.comparator && this.clause); + } else if (this.expressionType === EXPRESSION_TYPES.SQL) { + return !!(this.sqlExpression && this.clause); + } + return false; + } + + getDefaultLabel() { + const label = this.translateToSql(); + return label.length < 43 ? + label : + label.substring(0, 40) + '...'; + } + + translateToSql() { + return translateToSql(this); + } +} diff --git a/superset/assets/src/explore/AdhocMetric.js b/superset/assets/src/explore/AdhocMetric.js index 5c62f0544f..e069fd7359 100644 --- a/superset/assets/src/explore/AdhocMetric.js +++ b/superset/assets/src/explore/AdhocMetric.js @@ -50,14 +50,19 @@ export default class AdhocMetric { } getDefaultLabel() { + const label = this.translateToSql(); + return label.length < 43 ? + label : + label.substring(0, 40) + '...'; + } + + translateToSql() { if (this.expressionType === EXPRESSION_TYPES.SIMPLE) { return `${this.aggregate || ''}(${(this.column && this.column.column_name) || ''})`; } else if (this.expressionType === EXPRESSION_TYPES.SQL) { - return this.sqlExpression.length < 43 ? - this.sqlExpression : - this.sqlExpression.substring(0, 40) + '...'; + return this.sqlExpression; } - return 'malformatted metric'; + return ''; } duplicateWith(nextFields) { diff --git a/superset/assets/src/explore/components/AdhocFilterEditPopover.jsx b/superset/assets/src/explore/components/AdhocFilterEditPopover.jsx new file mode 100644 index 0000000000..7439ab3ad2 --- /dev/null +++ b/superset/assets/src/explore/components/AdhocFilterEditPopover.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Popover, Tab, Tabs } from 'react-bootstrap'; + +import columnType from '../propTypes/columnType'; +import adhocMetricType from '../propTypes/adhocMetricType'; +import AdhocFilter, { EXPRESSION_TYPES } from '../AdhocFilter'; +import AdhocFilterEditPopoverSimpleTabContent from './AdhocFilterEditPopoverSimpleTabContent'; +import AdhocFilterEditPopoverSqlTabContent from './AdhocFilterEditPopoverSqlTabContent'; + +const propTypes = { + adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired, + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + onResize: PropTypes.func.isRequired, + options: PropTypes.arrayOf(PropTypes.oneOfType([ + columnType, + PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }), + adhocMetricType, + ])).isRequired, + datasource: PropTypes.object, +}; + +const startingWidth = 300; +const startingHeight = 190; + +export default class AdhocFilterEditPopover extends React.Component { + constructor(props) { + super(props); + this.onSave = this.onSave.bind(this); + this.onDragDown = this.onDragDown.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + this.onAdhocFilterChange = this.onAdhocFilterChange.bind(this); + + this.state = { + adhocFilter: this.props.adhocFilter, + width: startingWidth, + height: startingHeight, + }; + } + + componentDidMount() { + document.addEventListener('mouseup', this.onMouseUp); + } + + componentWillUnmount() { + document.removeEventListener('mouseup', this.onMouseUp); + document.removeEventListener('mousemove', this.onMouseMove); + } + + onAdhocFilterChange(adhocFilter) { + this.setState({ adhocFilter }); + } + + onSave() { + this.props.onChange(this.state.adhocFilter); + this.props.onClose(); + } + + onDragDown(e) { + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.dragStartWidth = this.state.width; + this.dragStartHeight = this.state.height; + document.addEventListener('mousemove', this.onMouseMove); + } + + onMouseMove(e) { + this.props.onResize(); + this.setState({ + width: Math.max(this.dragStartWidth + (e.clientX - this.dragStartX), startingWidth), + height: Math.max(this.dragStartHeight + (e.clientY - this.dragStartY) * 2, startingHeight), + }); + } + + onMouseUp() { + document.removeEventListener('mousemove', this.onMouseMove); + } + + render() { + const { + adhocFilter: propsAdhocFilter, + options, + onChange, + onClose, + onResize, + datasource, + ...popoverProps + } = this.props; + + const { adhocFilter } = this.state; + + const stateIsValid = adhocFilter.isValid(); + const hasUnsavedChanges = !adhocFilter.equals(propsAdhocFilter); + + return ( + + + + + + { + (!this.props.datasource || this.props.datasource.type !== 'druid') && + + + + } + +
+ + + +
+
+ ); + } +} +AdhocFilterEditPopover.propTypes = propTypes; diff --git a/superset/assets/src/explore/components/AdhocFilterEditPopoverSimpleTabContent.jsx b/superset/assets/src/explore/components/AdhocFilterEditPopoverSimpleTabContent.jsx new file mode 100644 index 0000000000..b13fea1bdb --- /dev/null +++ b/superset/assets/src/explore/components/AdhocFilterEditPopoverSimpleTabContent.jsx @@ -0,0 +1,257 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormGroup } from 'react-bootstrap'; +import VirtualizedSelect from 'react-virtualized-select'; + +import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../AdhocFilter'; +import adhocMetricType from '../propTypes/adhocMetricType'; +import columnType from '../propTypes/columnType'; +import { t } from '../../locales'; +import { + OPERATORS, + TABLE_ONLY_OPERATORS, + DRUID_ONLY_OPERATORS, + HAVING_OPERATORS, + MULTI_OPERATORS, +} from '../constants'; +import FilterDefinitionOption from './FilterDefinitionOption'; +import OnPasteSelect from '../../components/OnPasteSelect'; +import SelectControl from './controls/SelectControl'; +import VirtualizedRendererWrap from '../../components/VirtualizedRendererWrap'; + +const $ = require('jquery'); + +const propTypes = { + adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired, + onChange: PropTypes.func.isRequired, + options: PropTypes.arrayOf(PropTypes.oneOfType([ + columnType, + PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }), + adhocMetricType, + ])).isRequired, + datasource: PropTypes.object, +}; + +const defaultProps = { + datasource: {}, +}; + +function translateOperator(operator) { + if (operator === OPERATORS['==']) { + return 'equals'; + } else if (operator === OPERATORS['!=']) { + return 'not equal to'; + } + return operator; +} + +export default class AdhocFilterEditPopoverSimpleTabContent extends React.Component { + constructor(props) { + super(props); + this.onSubjectChange = this.onSubjectChange.bind(this); + this.onOperatorChange = this.onOperatorChange.bind(this); + this.onComparatorChange = this.onComparatorChange.bind(this); + this.onInputComparatorChange = this.onInputComparatorChange.bind(this); + this.isOperatorRelevant = this.isOperatorRelevant.bind(this); + this.refreshComparatorSuggestions = this.refreshComparatorSuggestions.bind(this); + + this.state = { + suggestions: [], + }; + + this.selectProps = { + multi: false, + name: 'select-column', + labelKey: 'label', + autosize: false, + clearable: false, + selectWrap: VirtualizedSelect, + }; + } + + componentWillMount() { + this.refreshComparatorSuggestions(); + } + + componentDidUpdate(prevProps) { + if (prevProps.adhocFilter.subject !== this.props.adhocFilter.subject) { + this.refreshComparatorSuggestions(); + } + } + + onSubjectChange(option) { + let subject; + let clause; + // infer the new clause based on what subject was selected. + if (option && option.column_name) { + subject = option.column_name; + clause = CLAUSES.WHERE; + } else if (option && (option.saved_metric_name || option.label)) { + subject = option.saved_metric_name || option.label; + clause = CLAUSES.HAVING; + } + this.props.onChange(this.props.adhocFilter.duplicateWith({ + subject, + clause, + expressionType: EXPRESSION_TYPES.SIMPLE, + })); + } + + onOperatorChange(operator) { + const currentComparator = this.props.adhocFilter.comparator; + let newComparator; + // convert between list of comparators and individual comparators + // (e.g. `in ('North America', 'Africa')` to `== 'North America'`) + if (MULTI_OPERATORS.indexOf(operator.operator) >= 0) { + newComparator = Array.isArray(currentComparator) ? + currentComparator : + [currentComparator].filter(element => element); + } else { + newComparator = Array.isArray(currentComparator) ? currentComparator[0] : currentComparator; + } + this.props.onChange(this.props.adhocFilter.duplicateWith({ + operator: operator && operator.operator, + comparator: newComparator, + expressionType: EXPRESSION_TYPES.SIMPLE, + })); + } + + onInputComparatorChange(event) { + this.onComparatorChange(event.target.value); + } + + onComparatorChange(comparator) { + this.props.onChange(this.props.adhocFilter.duplicateWith({ + comparator, + expressionType: EXPRESSION_TYPES.SIMPLE, + })); + } + + refreshComparatorSuggestions() { + const datasource = this.props.datasource; + const col = this.props.adhocFilter.subject; + const having = this.props.adhocFilter.clause === CLAUSES.HAVING; + + if (col && datasource && datasource.filter_select && !having) { + if (this.state.activeRequest) { + this.state.activeRequest.abort(); + } + this.setState({ + activeRequest: $.ajax({ + type: 'GET', + url: `/superset/filter/${datasource.type}/${datasource.id}/${col}/`, + success: data => this.setState({ suggestions: data, activeRequest: null }), + }), + }); + } + } + + isOperatorRelevant(operator) { + return !( + (this.props.datasource.type === 'druid' && TABLE_ONLY_OPERATORS.indexOf(operator) >= 0) || + (this.props.datasource.type === 'table' && DRUID_ONLY_OPERATORS.indexOf(operator) >= 0) || + ( + this.props.adhocFilter.clause === CLAUSES.HAVING && + HAVING_OPERATORS.indexOf(operator) === -1 + ) + ); + } + + focusComparator(ref) { + if (ref) { + ref.focus(); + } + } + + render() { + const { adhocFilter, options, datasource } = this.props; + + let subjectSelectProps = { + value: adhocFilter.subject ? { value: adhocFilter.subject } : undefined, + onChange: this.onSubjectChange, + optionRenderer: VirtualizedRendererWrap(option => ( + + )), + valueRenderer: option => {option.value}, + valueKey: 'filterOptionName', + noResultsText: t('No such column found. To filter on a metric, try the Custom SQL tab.'), + }; + + if (datasource.type === 'druid') { + subjectSelectProps = { + ...subjectSelectProps, + placeholder: t('%s column(s) and metric(s)', options.length), + options, + }; + } else { + // we cannot support simple ad-hoc filters for metrics because we don't know what type + // the value should be cast to (without knowing the output type of the aggregate, which + // becomes a rather complicated problem) + subjectSelectProps = { + ...subjectSelectProps, + placeholder: adhocFilter.clause === CLAUSES.WHERE ? + t('%s column(s)', options.length) : + t('To filter on a metric, use Custom SQL tab.'), + options: options.filter(option => option.column_name), + }; + } + + const operatorSelectProps = { + placeholder: t('%s operators(s)', Object.keys(OPERATORS).length), + options: Object.keys(OPERATORS).filter(this.isOperatorRelevant).map(( + operator => ({ operator }) + )), + value: adhocFilter.operator, + onChange: this.onOperatorChange, + optionRenderer: VirtualizedRendererWrap(( + operator => translateOperator(operator.operator) + )), + valueRenderer: operator => ( + + {translateOperator(operator.operator)} + + ), + valueKey: 'operator', + }; + + return ( + + + + + + + + + { + ( + MULTI_OPERATORS.indexOf(adhocFilter.operator) >= 0 || + this.state.suggestions.length > 0 + ) ? + = 0} + freeForm + name="filter-comparator-value" + value={adhocFilter.comparator} + isLoading={false} + choices={this.state.suggestions} + onChange={this.onComparatorChange} + showHeader={false} + noResultsText={t('type a value here')} + /> : + + } + + + ); + } +} +AdhocFilterEditPopoverSimpleTabContent.propTypes = propTypes; +AdhocFilterEditPopoverSimpleTabContent.defaultProps = defaultProps; diff --git a/superset/assets/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx b/superset/assets/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx new file mode 100644 index 0000000000..8a3a97bd82 --- /dev/null +++ b/superset/assets/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx @@ -0,0 +1,121 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import AceEditor from 'react-ace'; +import 'brace/mode/sql'; +import 'brace/theme/github'; +import 'brace/ext/language_tools'; +import { FormGroup } from 'react-bootstrap'; +import VirtualizedSelect from 'react-virtualized-select'; + +import { sqlWords } from '../../SqlLab/components/AceEditorWrapper'; +import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../AdhocFilter'; +import adhocMetricType from '../propTypes/adhocMetricType'; +import columnType from '../propTypes/columnType'; +import OnPasteSelect from '../../components/OnPasteSelect'; +import VirtualizedRendererWrap from '../../components/VirtualizedRendererWrap'; +import { t } from '../../locales'; + +const propTypes = { + adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired, + onChange: PropTypes.func.isRequired, + options: PropTypes.arrayOf(PropTypes.oneOfType([ + columnType, + PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }), + adhocMetricType, + ])).isRequired, + height: PropTypes.number.isRequired, +}; + +const langTools = ace.acequire('ace/ext/language_tools'); + +export default class AdhocFilterEditPopoverSqlTabContent extends React.Component { + constructor(props) { + super(props); + this.onSqlExpressionChange = this.onSqlExpressionChange.bind(this); + this.onSqlExpressionClauseChange = this.onSqlExpressionClauseChange.bind(this); + + this.selectProps = { + multi: false, + name: 'select-column', + labelKey: 'label', + autosize: false, + clearable: false, + selectWrap: VirtualizedSelect, + }; + + if (langTools) { + const words = sqlWords.concat(this.props.options.map((option) => { + if (option.column_name) { + return { name: option.column_name, value: option.column_name, score: 50, meta: 'option' }; + } + return null; + })); + const completer = { + getCompletions: (aceEditor, session, pos, prefix, callback) => { + callback(null, words); + }, + }; + langTools.setCompleters([completer]); + } + } + + onSqlExpressionClauseChange(clause) { + this.props.onChange(this.props.adhocFilter.duplicateWith({ + clause: clause && clause.clause, + expressionType: EXPRESSION_TYPES.SQL, + })); + } + + onSqlExpressionChange(sqlExpression) { + this.props.onChange(this.props.adhocFilter.duplicateWith({ + sqlExpression, + expressionType: EXPRESSION_TYPES.SQL, + })); + } + + render() { + const { adhocFilter, height } = this.props; + + const clauseSelectProps = { + placeholder: t('choose WHERE or HAVING...'), + options: Object.keys(CLAUSES).map(clause => ({ clause })), + value: adhocFilter.clause, + onChange: this.onSqlExpressionClauseChange, + optionRenderer: VirtualizedRendererWrap(clause => clause.clause), + valueRenderer: clause => {clause.clause}, + valueKey: 'clause', + }; + + return ( + + + + + Where filters by columns.
+ Having filters by metrics. +
+
+ + + +
+ ); + } +} +AdhocFilterEditPopoverSqlTabContent.propTypes = propTypes; diff --git a/superset/assets/src/explore/components/AdhocFilterOption.jsx b/superset/assets/src/explore/components/AdhocFilterOption.jsx new file mode 100644 index 0000000000..eb7a5c16d6 --- /dev/null +++ b/superset/assets/src/explore/components/AdhocFilterOption.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Label, OverlayTrigger } from 'react-bootstrap'; + +import AdhocFilterEditPopover from './AdhocFilterEditPopover'; +import AdhocFilter from '../AdhocFilter'; +import columnType from '../propTypes/columnType'; +import adhocMetricType from '../propTypes/adhocMetricType'; + +const propTypes = { + adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired, + onFilterEdit: PropTypes.func.isRequired, + options: PropTypes.arrayOf(PropTypes.oneOfType([ + columnType, + PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }), + adhocMetricType, + ])).isRequired, + datasource: PropTypes.object, +}; + +export default class AdhocFilterOption extends React.PureComponent { + constructor(props) { + super(props); + this.closeFilterEditOverlay = this.closeFilterEditOverlay.bind(this); + this.onPopoverResize = this.onPopoverResize.bind(this); + this.onOverlayEntered = this.onOverlayEntered.bind(this); + this.onOverlayExited = this.onOverlayExited.bind(this); + this.state = { overlayShown: !this.props.adhocFilter.fromFormData }; + } + + onPopoverResize() { + this.forceUpdate(); + } + + onOverlayEntered() { + this.setState({ overlayShown: true }); + } + + onOverlayExited() { + this.setState({ overlayShown: false }); + } + + onMouseDown(e) { + e.stopPropagation(); + } + + closeFilterEditOverlay() { + this.refs.overlay.hide(); + } + + render() { + const { adhocFilter } = this.props; + const overlay = ( + + ); + + return ( + + + + ); + } +} +AdhocFilterOption.propTypes = propTypes; diff --git a/superset/assets/src/explore/components/AdhocMetricEditPopover.jsx b/superset/assets/src/explore/components/AdhocMetricEditPopover.jsx index 4fb8032089..24ac5b5fbc 100644 --- a/superset/assets/src/explore/components/AdhocMetricEditPopover.jsx +++ b/superset/assets/src/explore/components/AdhocMetricEditPopover.jsx @@ -218,13 +218,15 @@ export default class AdhocMetricEditPopover extends React.Component { diff --git a/superset/assets/src/explore/components/AdhocMetricOption.jsx b/superset/assets/src/explore/components/AdhocMetricOption.jsx index e7b270e806..482557a7a7 100644 --- a/superset/assets/src/explore/components/AdhocMetricOption.jsx +++ b/superset/assets/src/explore/components/AdhocMetricOption.jsx @@ -18,13 +18,24 @@ export default class AdhocMetricOption extends React.PureComponent { constructor(props) { super(props); this.closeMetricEditOverlay = this.closeMetricEditOverlay.bind(this); + this.onOverlayEntered = this.onOverlayEntered.bind(this); + this.onOverlayExited = this.onOverlayExited.bind(this); this.onPopoverResize = this.onPopoverResize.bind(this); + this.state = { overlayShown: !this.props.adhocMetric.fromFormData }; } onPopoverResize() { this.forceUpdate(); } + onOverlayEntered() { + this.setState({ overlayShown: true }); + } + + onOverlayExited() { + this.setState({ overlayShown: false }); + } + closeMetricEditOverlay() { this.refs.overlay.hide(); } @@ -52,11 +63,18 @@ export default class AdhocMetricOption extends React.PureComponent { rootClose shouldUpdatePosition defaultOverlayShown={!adhocMetric.fromFormData} + onEntered={this.onOverlayEntered} + onExited={this.onOverlayExited} > diff --git a/superset/assets/src/explore/components/AdhocMetricStaticOption.jsx b/superset/assets/src/explore/components/AdhocMetricStaticOption.jsx new file mode 100644 index 0000000000..bce6493ec3 --- /dev/null +++ b/superset/assets/src/explore/components/AdhocMetricStaticOption.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import ColumnTypeLabel from '../../components/ColumnTypeLabel'; +import adhocMetricType from '../propTypes/adhocMetricType'; + +const propTypes = { + adhocMetric: adhocMetricType, + showType: PropTypes.bool, +}; + +export default function AdhocMetricStaticOption({ adhocMetric, showType }) { + return ( +
+ {showType && } + + {adhocMetric.label} + +
+ ); +} +AdhocMetricStaticOption.propTypes = propTypes; diff --git a/superset/assets/src/explore/components/Control.jsx b/superset/assets/src/explore/components/Control.jsx index 25d69a5be4..52682dee00 100644 --- a/superset/assets/src/explore/components/Control.jsx +++ b/superset/assets/src/explore/components/Control.jsx @@ -19,6 +19,7 @@ const propTypes = { validationErrors: PropTypes.array, renderTrigger: PropTypes.bool, rightNode: PropTypes.node, + formData: PropTypes.object, value: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, diff --git a/superset/assets/src/explore/components/ControlPanelsContainer.jsx b/superset/assets/src/explore/components/ControlPanelsContainer.jsx index cb2cd7965c..1bf653f938 100644 --- a/superset/assets/src/explore/components/ControlPanelsContainer.jsx +++ b/superset/assets/src/explore/components/ControlPanelsContainer.jsx @@ -78,6 +78,7 @@ class ControlPanelsContainer extends React.Component { value={this.props.form_data[controlName]} validationErrors={ctrls[controlName].validationErrors} actions={this.props.actions} + formData={ctrls[controlName].provideFormDataToProps ? this.props.form_data : null} {...this.getControlData(controlName)} /> ))} diff --git a/superset/assets/src/explore/components/FilterDefinitionOption.jsx b/superset/assets/src/explore/components/FilterDefinitionOption.jsx new file mode 100644 index 0000000000..34355f75d5 --- /dev/null +++ b/superset/assets/src/explore/components/FilterDefinitionOption.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import ColumnOption from '../../components/ColumnOption'; +import ColumnTypeLabel from '../../components/ColumnTypeLabel'; +import AdhocMetricStaticOption from './AdhocMetricStaticOption'; +import columnType from '../propTypes/columnType'; +import adhocMetricType from '../propTypes/adhocMetricType'; + +const propTypes = { + option: PropTypes.oneOfType([ + columnType, + PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }), + adhocMetricType, + ]).isRequired, +}; + +export default function FilterDefinitionOption({ option }) { + if (option.saved_metric_name) { + return ( +
+ + + {option.saved_metric_name} + +
+ ); + } else if (option.column_name) { + return ( + + ); + } else if (option.label) { + return ( + + ); + } +} +FilterDefinitionOption.propTypes = propTypes; diff --git a/superset/assets/src/explore/components/controls/AdhocFilterControl.jsx b/superset/assets/src/explore/components/controls/AdhocFilterControl.jsx new file mode 100644 index 0000000000..abd8778f40 --- /dev/null +++ b/superset/assets/src/explore/components/controls/AdhocFilterControl.jsx @@ -0,0 +1,259 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import VirtualizedSelect from 'react-virtualized-select'; + +import { t } from '../../../locales'; +import ControlHeader from '../ControlHeader'; +import adhocFilterType from '../../propTypes/adhocFilterType'; +import adhocMetricType from '../../propTypes/adhocMetricType'; +import savedMetricType from '../../propTypes/savedMetricType'; +import columnType from '../../propTypes/columnType'; +import AdhocFilter, { CLAUSES, EXPRESSION_TYPES } from '../../AdhocFilter'; +import AdhocMetric from '../../AdhocMetric'; +import { OPERATORS } from '../../constants'; +import VirtualizedRendererWrap from '../../../components/VirtualizedRendererWrap'; +import OnPasteSelect from '../../../components/OnPasteSelect'; +import AdhocFilterOption from '../AdhocFilterOption'; +import FilterDefinitionOption from '../FilterDefinitionOption'; + +const legacyFilterShape = PropTypes.shape({ + col: PropTypes.string, + op: PropTypes.string, + val: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), +}); + +const propTypes = { + name: PropTypes.string, + onChange: PropTypes.func, + value: PropTypes.arrayOf(adhocFilterType), + datasource: PropTypes.object, + columns: PropTypes.arrayOf(columnType), + savedMetrics: PropTypes.arrayOf(savedMetricType), + formData: PropTypes.shape({ + filters: PropTypes.arrayOf(legacyFilterShape), + having: PropTypes.string, + having_filters: PropTypes.arrayOf(legacyFilterShape), + metric: PropTypes.oneOfType([PropTypes.string, adhocMetricType]), + metrics: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, adhocMetricType])), + where: PropTypes.string, + }), +}; + +const defaultProps = { + name: '', + onChange: () => {}, + columns: [], + savedMetrics: [], + formData: {}, +}; + +function isDictionaryForAdhocFilter(value) { + return value && !(value instanceof AdhocFilter) && value.expressionType; +} + +export default class AdhocFilterControl extends React.Component { + + constructor(props) { + super(props); + this.coerceAdhocFilters = this.coerceAdhocFilters.bind(this); + this.optionsForSelect = this.optionsForSelect.bind(this); + this.onFilterEdit = this.onFilterEdit.bind(this); + this.onChange = this.onChange.bind(this); + this.getMetricExpression = this.getMetricExpression.bind(this); + + const filters = this.coerceAdhocFilters(this.props.value, this.props.formData); + this.optionRenderer = VirtualizedRendererWrap(option => ( + + )); + this.valueRenderer = adhocFilter => ( + + ); + this.state = { + values: filters, + options: this.optionsForSelect(this.props), + }; + } + + componentWillReceiveProps(nextProps) { + if ( + this.props.columns !== nextProps.columns || + this.props.formData !== nextProps.formData + ) { + this.setState({ options: this.optionsForSelect(nextProps) }); + } + if (this.props.value !== nextProps.value) { + this.setState({ values: this.coerceAdhocFilters(nextProps.value, nextProps.formData) }); + } + } + + onFilterEdit(changedFilter) { + this.props.onChange(this.state.values.map((value) => { + if (value.filterOptionName === changedFilter.filterOptionName) { + return changedFilter; + } + return value; + })); + } + + onChange(opts) { + this.props.onChange(opts.map((option) => { + if (option.saved_metric_name) { + return new AdhocFilter({ + expressionType: this.props.datasource.type === 'druid' ? + EXPRESSION_TYPES.SIMPLE : + EXPRESSION_TYPES.SQL, + subject: this.props.datasource.type === 'druid' ? + option.saved_metric_name : + this.getMetricExpression(option.saved_metric_name), + operator: OPERATORS['>'], + comparator: 0, + clause: CLAUSES.HAVING, + }); + } else if (option.label) { + return new AdhocFilter({ + expressionType: this.props.datasource.type === 'druid' ? + EXPRESSION_TYPES.SIMPLE : + EXPRESSION_TYPES.SQL, + subject: this.props.datasource.type === 'druid' ? + option.label : + new AdhocMetric(option).translateToSql(), + operator: OPERATORS['>'], + comparator: 0, + clause: CLAUSES.HAVING, + }); + } else if (option.column_name) { + return new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: option.column_name, + operator: OPERATORS['=='], + comparator: '', + clause: CLAUSES.WHERE, + }); + } else if (option instanceof AdhocFilter) { + return option; + } + return null; + }).filter(option => option)); + } + + getMetricExpression(savedMetricName) { + return this.props.savedMetrics.find(( + savedMetric => savedMetric.metric_name === savedMetricName + )).expression; + } + + coerceAdhocFilters(propsValues, formData) { + // this converts filters from the four legacy filter controls into adhoc filters in the case + // someone loads an old slice in the explore view + if (propsValues) { + return propsValues.map(filter => ( + isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter + )); + } + return [ + ...(formData.filters || []).map(filter => ( + new AdhocFilter({ + subject: filter.col, + operator: filter.op, + comparator: filter.val, + clause: CLAUSES.WHERE, + expressionType: EXPRESSION_TYPES.SIMPLE, + filterOptionName: this.generateConvertedFilterOptionName(), + }) + )), + ...(formData.having_filters || []).map(filter => ( + new AdhocFilter({ + subject: filter.col, + operator: filter.op, + comparator: filter.val, + clause: CLAUSES.HAVING, + expressionType: EXPRESSION_TYPES.SIMPLE, + filterOptionName: this.generateConvertedFilterOptionName(), + }) + )), + ...[ + formData.where ? + new AdhocFilter({ + sqlExpression: formData.where, + clause: CLAUSES.WHERE, + expressionType: EXPRESSION_TYPES.SQL, + filterOptionName: this.generateConvertedFilterOptionName(), + }) : + null, + ], + ...[ + formData.having ? + new AdhocFilter({ + sqlExpression: formData.having, + clause: CLAUSES.HAVING, + expressionType: EXPRESSION_TYPES.SQL, + filterOptionName: this.generateConvertedFilterOptionName(), + }) : + null, + ], + ].filter(option => option); + } + + generateConvertedFilterOptionName() { + return `form_filter_${Math.random().toString(36).substring(2, 15)}_${Math.random().toString(36).substring(2, 15)}`; + } + + optionsForSelect(props) { + const options = [ + ...props.columns, + ...[...props.formData.metrics, props.formData.metric].map(metric => ( + metric && ( + typeof metric === 'string' ? + { saved_metric_name: metric } : + new AdhocMetric(metric) + ) + )), + ].filter(option => option); + + return options.map((option) => { + if (option.saved_metric_name) { + return { ...option, filterOptionName: option.saved_metric_name }; + } else if (option.column_name) { + return { ...option, filterOptionName: '_col_' + option.column_name }; + } else if (option instanceof AdhocMetric) { + return { ...option, filterOptionName: '_adhocmetric_' + option.label }; + } + return null; + }).sort((a, b) => ( + (a.saved_metric_name || a.column_name || a.label || '').localeCompare(( + b.saved_metric_name || b.column_name || b.label + )) + )); + } + + render() { + return ( +
+ + +
+ ); + } +} + +AdhocFilterControl.propTypes = propTypes; +AdhocFilterControl.defaultProps = defaultProps; diff --git a/superset/assets/src/explore/components/controls/SelectControl.jsx b/superset/assets/src/explore/components/controls/SelectControl.jsx index 16cb95e094..d2f3543068 100644 --- a/superset/assets/src/explore/components/controls/SelectControl.jsx +++ b/superset/assets/src/explore/components/controls/SelectControl.jsx @@ -26,6 +26,7 @@ const propTypes = { valueKey: PropTypes.string, options: PropTypes.array, placeholder: PropTypes.string, + noResultsText: PropTypes.string, }; const defaultProps = { @@ -43,6 +44,7 @@ const defaultProps = { optionRenderer: opt => opt.label, valueRenderer: opt => opt.label, valueKey: 'value', + noResultsText: t('No results found'), }; export default class SelectControl extends React.PureComponent { @@ -124,6 +126,7 @@ export default class SelectControl extends React.PureComponent { onFocus: this.props.onFocus, optionRenderer: VirtualizedRendererWrap(this.props.optionRenderer), valueRenderer: this.props.valueRenderer, + noResultsText: this.props.noResultsText, selectComponent: this.props.freeForm ? Creatable : Select, disabled: this.props.disabled, }; diff --git a/superset/assets/src/explore/components/controls/index.js b/superset/assets/src/explore/components/controls/index.js index a7ca463605..81991275ea 100644 --- a/superset/assets/src/explore/components/controls/index.js +++ b/superset/assets/src/explore/components/controls/index.js @@ -18,6 +18,7 @@ import TimeSeriesColumnControl from './TimeSeriesColumnControl'; import ViewportControl from './ViewportControl'; import VizTypeControl from './VizTypeControl'; import MetricsControl from './MetricsControl'; +import AdhocFilterControl from './AdhocFilterControl'; const controlMap = { AnnotationLayerControl, @@ -40,5 +41,6 @@ const controlMap = { ViewportControl, VizTypeControl, MetricsControl, + AdhocFilterControl, }; export default controlMap; diff --git a/superset/assets/src/explore/constants.js b/superset/assets/src/explore/constants.js index 0a92dfd63b..52395305d7 100644 --- a/superset/assets/src/explore/constants.js +++ b/superset/assets/src/explore/constants.js @@ -7,6 +7,30 @@ export const AGGREGATES = { SUM: 'SUM', }; +export const OPERATORS = { + '==': '==', + '!=': '!=', + '>': '>', + '<': '<', + '>=': '>=', + '<=': '<=', + in: 'in', + 'not in': 'not in', + like: 'like', + regex: 'regex', +}; + +export const TABLE_ONLY_OPERATORS = [OPERATORS.like]; +export const DRUID_ONLY_OPERATORS = [OPERATORS.regex]; +export const HAVING_OPERATORS = [ + OPERATORS['=='], + OPERATORS['!='], + OPERATORS['>'], + OPERATORS['<'], + OPERATORS['>='], + OPERATORS['<='], +]; +export const MULTI_OPERATORS = [OPERATORS.in, OPERATORS['not in']]; + export const sqlaAutoGeneratedMetricRegex = /^(LONG|DOUBLE|FLOAT)?(SUM|AVG|MAX|MIN|COUNT)\([A-Z_][A-Z0-9_]*\)$/i; export const druidAutoGeneratedMetricRegex = /^(LONG|DOUBLE|FLOAT)?(SUM|MAX|MIN|COUNT)\([A-Z_][A-Z0-9_]*\)$/i; - diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx index 8dbba958f0..bc5dc4689d 100644 --- a/superset/assets/src/explore/controls.jsx +++ b/superset/assets/src/explore/controls.jsx @@ -1867,6 +1867,19 @@ export const controls = { tabOverride: 'data', }, + adhoc_filters: { + type: 'AdhocFilterControl', + label: t('Filters'), + default: null, + description: '', + mapStateToProps: state => ({ + columns: state.datasource ? state.datasource.columns : [], + savedMetrics: state.datasource ? state.datasource.metrics : [], + datasource: state.datasource, + }), + provideFormDataToProps: true, + }, + having_filters: { type: 'FilterControl', label: '', diff --git a/superset/assets/src/explore/main.css b/superset/assets/src/explore/main.css index 946f21915d..40047fa9cc 100644 --- a/superset/assets/src/explore/main.css +++ b/superset/assets/src/explore/main.css @@ -147,6 +147,14 @@ padding: 4px 4px 4px 4px; } +.adhoc-filter-edit-tabs > .nav-tabs { + margin-bottom: 8px; +} + +.adhoc-filter-edit-tabs > .nav-tabs > li > a { + padding: 4px; +} + .edit-popover-resize { transform: scaleX(-1); -moz-transform: scaleX(-1); @@ -161,3 +169,44 @@ #metrics-edit-popover { max-width: none; } + +#filter-edit-popover { + max-width: none; +} + +.filter-edit-clause-dropdown { + width: 120px; + margin-right: 5px; +} + +.filter-edit-clause-info { + font-size: 10px; + padding-left: 5px; +} + +.filter-edit-clause-section { + display: inline-flex; +} + +.adhoc-filter-option{ + cursor: pointer; +} + +.adhoc-filter-sql-editor { + border: rgb(187, 187, 187) solid thin; +} + +.label-default { + background-color: #808e95; + font-weight: normal; +} + +.adhoc-filter-simple-column-dropdown { + margin-top: 20px; +} + +.adhoc-label-arrow { + font-size: 9px; + margin-left: 3px; + position: static; +} diff --git a/superset/assets/src/explore/propTypes/adhocFilterType.js b/superset/assets/src/explore/propTypes/adhocFilterType.js new file mode 100644 index 0000000000..d09e4f81ec --- /dev/null +++ b/superset/assets/src/explore/propTypes/adhocFilterType.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; + +import { OPERATORS } from '../constants'; +import { EXPRESSION_TYPES, CLAUSES } from '../AdhocFilter'; + +export default PropTypes.oneOfType([ + PropTypes.shape({ + expressionType: PropTypes.oneOf([EXPRESSION_TYPES.SIMPLE]).isRequired, + clause: PropTypes.oneOf([CLAUSES.HAVING, CLAUSES.WHERE]).isRequired, + subject: PropTypes.string.isRequired, + operator: PropTypes.oneOf(Object.keys(OPERATORS)).isRequired, + comparator: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]).isRequired, + }), + PropTypes.shape({ + expressionType: PropTypes.oneOf([EXPRESSION_TYPES.SQL]).isRequired, + clause: PropTypes.oneOf([CLAUSES.WHERE, CLAUSES.HAVING]).isRequired, + sqlExpression: PropTypes.string.isRequired, + }), +]); diff --git a/superset/assets/src/explore/visTypes.js b/superset/assets/src/explore/visTypes.js index 832c1db715..35e49d4587 100644 --- a/superset/assets/src/explore/visTypes.js +++ b/superset/assets/src/explore/visTypes.js @@ -61,6 +61,7 @@ export const sections = { expanded: true, controlSetRows: [ ['metrics'], + ['adhoc_filters'], ['groupby'], ['limit', 'timeseries_limit_metric'], ['order_desc', 'contribution'], @@ -114,6 +115,7 @@ export const visTypes = { expanded: true, controlSetRows: [ ['metrics'], + ['adhoc_filters'], ['groupby'], ['columns'], ['row_limit'], @@ -160,6 +162,7 @@ export const visTypes = { expanded: true, controlSetRows: [ ['metrics'], + ['adhoc_filters'], ['groupby'], ['limit'], ], @@ -1123,6 +1126,7 @@ export const visTypes = { expanded: true, controlSetRows: [ ['metric'], + ['adhoc_filters'], ], }, { @@ -1149,6 +1153,7 @@ export const visTypes = { expanded: true, controlSetRows: [ ['metric'], + ['adhoc_filters'], ], }, { @@ -1718,13 +1723,19 @@ export const visTypes = { export default visTypes; +function adhocFilterEnabled(viz) { + return viz.controlPanelSections.find(( + section => section.controlSetRows.find(row => row.find(control => control === 'adhoc_filters')) + )); +} + export function sectionsToRender(vizType, datasourceType) { const viz = visTypes[vizType]; return [].concat( sections.datasourceAndVizType, datasourceType === 'table' ? sections.sqlaTimeSeries : sections.druidTimeSeries, viz.controlPanelSections, - datasourceType === 'table' ? sections.sqlClause : [], - datasourceType === 'table' ? sections.filters[0] : sections.filters, - ); + !adhocFilterEnabled(viz) && (datasourceType === 'table' ? sections.sqlClause : []), + !adhocFilterEnabled(viz) && (datasourceType === 'table' ? sections.filters[0] : sections.filters), + ).filter(section => section); } diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index 079648fe73..3cece6ec34 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -1246,7 +1246,11 @@ class DruidDatasource(Model, BaseDatasource): dict_dims = [x for x in pre_qry_dims if isinstance(x, dict)] pre_qry['dimensions'] = non_dict_dims + dict_dims - order_by = metrics[0] if metrics else pre_qry_dims[0] + order_by = None + if metrics: + order_by = utils.get_metric_name(metrics[0]) + else: + order_by = pre_qry_dims[0] if timeseries_limit_metric: order_by = timeseries_limit_metric @@ -1296,7 +1300,10 @@ class DruidDatasource(Model, BaseDatasource): 'limit': row_limit, 'columns': [{ 'dimension': ( - metrics[0] if metrics else dimension_values[0]), + utils.get_metric_name( + metrics[0], + ) if metrics else dimension_values[0] + ), 'direction': order_direction, }], } diff --git a/superset/utils.py b/superset/utils.py index bd3d729f85..25d4ef38da 100644 --- a/superset/utils.py +++ b/superset/utils.py @@ -827,8 +827,12 @@ def is_adhoc_metric(metric): ) +def get_metric_name(metric): + return metric['label'] if is_adhoc_metric(metric) else metric + + def get_metric_names(metrics): - return [metric['label'] if is_adhoc_metric(metric) else metric for metric in metrics] + return [get_metric_name(metric) for metric in metrics] def ensure_path_exists(path): diff --git a/superset/viz.py b/superset/viz.py index 7eb34c68c6..1e3fcb5ae5 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -253,14 +253,56 @@ class BaseViz(object): # extras are used to query elements specific to a datasource type # for instance the extra where clause that applies only to Tables - extras = { - 'where': form_data.get('where', ''), - 'having': form_data.get('having', ''), - 'having_druid': form_data.get('having_filters', []), - 'time_grain_sqla': form_data.get('time_grain_sqla', ''), - 'druid_time_origin': form_data.get('druid_time_origin', ''), - } - filters = form_data.get('filters', []) + + extras = {} + filters = [] + adhoc_filters = form_data.get('adhoc_filters', None) + if adhoc_filters is None: + extras = { + 'where': form_data.get('where', ''), + 'having': form_data.get('having', ''), + 'having_druid': form_data.get('having_filters', []), + 'time_grain_sqla': form_data.get('time_grain_sqla', ''), + 'druid_time_origin': form_data.get('druid_time_origin', ''), + } + filters = form_data.get('filters', []) + elif isinstance(adhoc_filters, list): + simple_where_filters = [] + simple_having_filters = [] + sql_where_filters = [] + sql_having_filters = [] + for adhoc_filter in adhoc_filters: + expression_type = adhoc_filter.get('expressionType') + clause = adhoc_filter.get('clause') + if expression_type == 'SIMPLE': + if clause == 'WHERE': + simple_where_filters.append({ + 'col': adhoc_filter.get('subject'), + 'op': adhoc_filter.get('operator'), + 'val': adhoc_filter.get('comparator'), + }) + elif clause == 'HAVING': + simple_having_filters.append({ + 'col': adhoc_filter.get('subject'), + 'op': adhoc_filter.get('operator'), + 'val': adhoc_filter.get('comparator'), + }) + elif expression_type == 'SQL': + if clause == 'WHERE': + sql_where_filters.append(adhoc_filter.get('sqlExpression')) + elif clause == 'HAVING': + sql_having_filters.append(adhoc_filter.get('sqlExpression')) + extras = { + 'where': ' AND '.join(['({})'.format(sql) for sql in sql_where_filters]), + 'having': ' AND '.join( + ['({})'.format(sql) for sql in sql_having_filters], + ), + 'having_druid': simple_having_filters, + 'time_grain_sqla': form_data.get('time_grain_sqla', ''), + 'druid_time_origin': form_data.get('druid_time_origin', ''), + } + filters = simple_where_filters + d = { 'granularity': granularity, 'from_dttm': from_dttm, diff --git a/tests/viz_tests.py b/tests/viz_tests.py index 1762dc863a..fb56581434 100644 --- a/tests/viz_tests.py +++ b/tests/viz_tests.py @@ -164,6 +164,120 @@ class TableVizTestCase(unittest.TestCase): ] self.assertEqual(expected, data['records']) + def test_parse_adhoc_filters(self): + form_data = { + 'metrics': [{ + 'expressionType': 'SIMPLE', + 'aggregate': 'SUM', + 'label': 'SUM(value1)', + 'column': {'column_name': 'value1', 'type': 'DOUBLE'}, + }], + 'adhoc_filters': [ + { + 'expressionType': 'SIMPLE', + 'clause': 'WHERE', + 'subject': 'value2', + 'operator': '>', + 'comparator': '100', + }, + { + 'expressionType': 'SIMPLE', + 'clause': 'HAVING', + 'subject': 'SUM(value1)', + 'operator': '<', + 'comparator': '10', + }, + { + 'expressionType': 'SQL', + 'clause': 'HAVING', + 'sqlExpression': 'SUM(value1) > 5', + }, + { + 'expressionType': 'SQL', + 'clause': 'WHERE', + 'sqlExpression': 'value3 in (\'North America\')', + }, + ], + } + datasource = Mock() + test_viz = viz.TableViz(datasource, form_data) + query_obj = test_viz.query_obj() + self.assertEqual( + [{'col': 'value2', 'val': '100', 'op': '>'}], + query_obj['filter'], + ) + self.assertEqual( + [{'op': '<', 'val': '10', 'col': 'SUM(value1)'}], + query_obj['extras']['having_druid'], + ) + self.assertEqual('(value3 in (\'North America\'))', query_obj['extras']['where']) + self.assertEqual('(SUM(value1) > 5)', query_obj['extras']['having']) + + def test_adhoc_filters_overwrite_legacy_filters(self): + form_data = { + 'metrics': [{ + 'expressionType': 'SIMPLE', + 'aggregate': 'SUM', + 'label': 'SUM(value1)', + 'column': {'column_name': 'value1', 'type': 'DOUBLE'}, + }], + 'adhoc_filters': [ + { + 'expressionType': 'SIMPLE', + 'clause': 'WHERE', + 'subject': 'value2', + 'operator': '>', + 'comparator': '100', + }, + { + 'expressionType': 'SQL', + 'clause': 'WHERE', + 'sqlExpression': 'value3 in (\'North America\')', + }, + ], + 'having': 'SUM(value1) > 5', + } + datasource = Mock() + test_viz = viz.TableViz(datasource, form_data) + query_obj = test_viz.query_obj() + self.assertEqual( + [{'col': 'value2', 'val': '100', 'op': '>'}], + query_obj['filter'], + ) + self.assertEqual( + [], + query_obj['extras']['having_druid'], + ) + self.assertEqual('(value3 in (\'North America\'))', query_obj['extras']['where']) + self.assertEqual('', query_obj['extras']['having']) + + def test_legacy_filters_still_appear_without_adhoc_filters(self): + form_data = { + 'metrics': [{ + 'expressionType': 'SIMPLE', + 'aggregate': 'SUM', + 'label': 'SUM(value1)', + 'column': {'column_name': 'value1', 'type': 'DOUBLE'}, + }], + 'having': 'SUM(value1) > 5', + 'where': 'value3 in (\'North America\')', + 'filters': [{'col': 'value2', 'val': '100', 'op': '>'}], + 'having_filters': [{'op': '<', 'val': '10', 'col': 'SUM(value1)'}], + } + datasource = Mock() + test_viz = viz.TableViz(datasource, form_data) + query_obj = test_viz.query_obj() + self.assertEqual( + [{'col': 'value2', 'val': '100', 'op': '>'}], + query_obj['filter'], + ) + self.assertEqual( + [{'op': '<', 'val': '10', 'col': 'SUM(value1)'}], + query_obj['extras']['having_druid'], + ) + self.assertEqual('value3 in (\'North America\')', query_obj['extras']['where']) + self.assertEqual('SUM(value1) > 5', query_obj['extras']['having']) + @patch('superset.viz.BaseViz.query_obj') def test_query_obj_merges_percent_metrics(self, super_query_obj): datasource = Mock()