You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by GitBox <gi...@apache.org> on 2018/05/10 17:41:13 UTC

[GitHub] graceguo-supercat closed pull request #4909: [Explore] Adding Adhoc Filters

graceguo-supercat closed pull request #4909: [Explore] Adding Adhoc Filters
URL: https://github.com/apache/incubator-superset/pull/4909
 
 
   

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

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

diff --git a/superset/assets/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(<AdhocFilterControl {...props} />);
+  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(<AdhocFilterEditPopoverSimpleTabContent {...props} />);
+  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(<AdhocFilterEditPopoverSqlTabContent {...props} />);
+  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(<AdhocFilterEditPopover {...props} />);
+  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(<AdhocFilterOption {...props} />);
+  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(<AdhocMetricStaticOption adhocMetric={sumValueAdhocMetric} />);
+    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(<FilterDefinitionOption option={{ column_name: 'a_column' }} />);
+    expect(wrapper.find(ColumnOption)).to.have.lengthOf(1);
+  });
+
+  it('renders a AdhocMetricStaticOption given an adhoc metric', () => {
+    const wrapper = shallow(<FilterDefinitionOption option={sumValueAdhocMetric} />);
+    expect(wrapper.find(AdhocMetricStaticOption)).to.have.lengthOf(1);
+  });
+
+  it('renders the metric name given a saved metric', () => {
+    const wrapper = shallow((
+      <FilterDefinitionOption option={{ saved_metric_name: 'my_custom_metric' }} />
+    ));
+    expect(wrapper.text()).to.equal('<ColumnTypeLabel />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 (
+      <Popover
+        id="filter-edit-popover"
+        {...popoverProps}
+      >
+        <Tabs
+          id="adhoc-filter-edit-tabs"
+          defaultActiveKey={adhocFilter.expressionType}
+          className="adhoc-filter-edit-tabs"
+          style={{ height: this.state.height, width: this.state.width }}
+        >
+          <Tab
+            className="adhoc-filter-edit-tab"
+            eventKey={EXPRESSION_TYPES.SIMPLE}
+            title="Simple"
+          >
+            <AdhocFilterEditPopoverSimpleTabContent
+              adhocFilter={this.state.adhocFilter}
+              onChange={this.onAdhocFilterChange}
+              options={this.props.options}
+              datasource={this.props.datasource}
+            />
+          </Tab>
+          {
+            (!this.props.datasource || this.props.datasource.type !== 'druid') &&
+            <Tab
+              className="adhoc-filter-edit-tab"
+              eventKey={EXPRESSION_TYPES.SQL}
+              title="Custom SQL"
+            >
+              <AdhocFilterEditPopoverSqlTabContent
+                adhocFilter={this.state.adhocFilter}
+                onChange={this.onAdhocFilterChange}
+                options={this.props.options}
+                height={this.state.height}
+              />
+            </Tab>
+          }
+        </Tabs>
+        <div>
+          <Button
+            disabled={!stateIsValid}
+            bsStyle={(hasUnsavedChanges && stateIsValid) ? 'primary' : 'default'}
+            bsSize="small"
+            className="m-r-5"
+            onClick={this.onSave}
+          >
+            Save
+          </Button>
+          <Button bsSize="small" onClick={this.props.onClose}>Close</Button>
+          <i onMouseDown={this.onDragDown} className="glyphicon glyphicon-resize-full edit-popover-resize" />
+        </div>
+      </Popover>
+    );
+  }
+}
+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 => (
+        <FilterDefinitionOption option={option} />
+      )),
+      valueRenderer: option => <span>{option.value}</span>,
+      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 => (
+        <span>
+          {translateOperator(operator.operator)}
+        </span>
+      ),
+      valueKey: 'operator',
+    };
+
+    return (
+      <span>
+        <FormGroup className="adhoc-filter-simple-column-dropdown">
+          <OnPasteSelect {...this.selectProps} {...subjectSelectProps} />
+        </FormGroup>
+        <FormGroup>
+          <OnPasteSelect {...this.selectProps} {...operatorSelectProps} />
+        </FormGroup>
+        <FormGroup>
+          {
+            (
+              MULTI_OPERATORS.indexOf(adhocFilter.operator) >= 0 ||
+              this.state.suggestions.length > 0
+            ) ?
+              <SelectControl
+                multi={MULTI_OPERATORS.indexOf(adhocFilter.operator) >= 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')}
+              /> :
+              <input
+                ref={this.focusComparator}
+                type="text"
+                onChange={this.onInputComparatorChange}
+                value={adhocFilter.comparator || ''}
+                className="form-control input-sm"
+                placeholder={t('Filter value')}
+              />
+          }
+        </FormGroup>
+      </span>
+    );
+  }
+}
+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 => <span>{clause.clause}</span>,
+      valueKey: 'clause',
+    };
+
+    return (
+      <span>
+        <FormGroup className="filter-edit-clause-section">
+          <OnPasteSelect
+            {...this.selectProps}
+            {...clauseSelectProps}
+            className="filter-edit-clause-dropdown"
+          />
+          <span className="filter-edit-clause-info">
+            <strong>Where</strong> filters by columns.<br />
+            <strong>Having</strong> filters by metrics.
+          </span>
+        </FormGroup>
+        <FormGroup>
+          <AceEditor
+            mode="sql"
+            theme="github"
+            height={(height - 100) + 'px'}
+            onChange={this.onSqlExpressionChange}
+            width="100%"
+            showGutter={false}
+            value={adhocFilter.sqlExpression || adhocFilter.translateToSql()}
+            editorProps={{ $blockScrolling: true }}
+            enableLiveAutocompletion
+            className="adhoc-filter-sql-editor"
+            wrapEnabled
+          />
+        </FormGroup>
+      </span>
+    );
+  }
+}
+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 = (
+      <AdhocFilterEditPopover
+        onResize={this.onPopoverResize}
+        adhocFilter={adhocFilter}
+        onChange={this.props.onFilterEdit}
+        onClose={this.closeFilterEditOverlay}
+        options={this.props.options}
+        datasource={this.props.datasource}
+      />
+    );
+
+    return (
+      <OverlayTrigger
+        ref="overlay"
+        placement="right"
+        trigger="click"
+        disabled
+        overlay={overlay}
+        rootClose
+        shouldUpdatePosition
+        defaultOverlayShown={!adhocFilter.fromFormData}
+        onEntered={this.onOverlayEntered}
+        onExited={this.onOverlayExited}
+      >
+        <Label className="adhoc-filter-option">
+          <div onMouseDownCapture={this.onMouseDown}>
+            <span className="m-r-5 option-label">
+              {adhocFilter.getDefaultLabel()}
+              <i
+                className={
+                  `glyphicon glyphicon-triangle-${this.state.overlayShown ? 'left' : 'right'} adhoc-label-arrow`
+                }
+              />
+            </span>
+          </div>
+        </Label>
+      </OverlayTrigger>
+    );
+  }
+}
+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 {
                 <AceEditor
                   mode="sql"
                   theme="github"
-                  height={(this.state.height - 40) + 'px'}
+                  height={(this.state.height - 43) + 'px'}
                   onChange={this.onSqlExpressionChange}
                   width="100%"
                   showGutter={false}
-                  value={adhocMetric.sqlExpression || adhocMetric.getDefaultLabel()}
+                  value={adhocMetric.sqlExpression || adhocMetric.translateToSql()}
                   editorProps={{ $blockScrolling: true }}
                   enableLiveAutocompletion
+                  className="adhoc-filter-sql-editor"
+                  wrapEnabled
                 />
               </FormGroup>
             </Tab>
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}
       >
         <Label style={{ margin: this.props.multi ? 0 : 3, cursor: 'pointer' }}>
           <div onMouseDownCapture={(e) => { e.stopPropagation(); }}>
             <span className="m-r-5 option-label">
               {adhocMetric.label}
+              <i
+                className={
+                  `glyphicon glyphicon-triangle-${this.state.overlayShown ? 'left' : 'right'} adhoc-label-arrow`
+                }
+              />
             </span>
           </div>
         </Label>
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 (
+    <div>
+      {showType && <ColumnTypeLabel type="expression" />}
+      <span className="m-r-5 option-label">
+        {adhocMetric.label}
+      </span>
+    </div>
+  );
+}
+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 (
+      <div>
+        <ColumnTypeLabel type="expression" />
+        <span className="m-r-5 option-label">
+          {option.saved_metric_name}
+        </span>
+      </div>
+    );
+  } else if (option.column_name) {
+    return (
+      <ColumnOption column={option} showType />
+    );
+  } else if (option.label) {
+    return (
+      <AdhocMetricStaticOption adhocMetric={option} showType />
+    );
+  }
+}
+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 => (
+      <FilterDefinitionOption option={option} />
+    ));
+    this.valueRenderer = adhocFilter => (
+      <AdhocFilterOption
+        adhocFilter={adhocFilter}
+        onFilterEdit={this.onFilterEdit}
+        options={this.state.options}
+        datasource={this.props.datasource}
+      />
+    );
+    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 (
+      <div className="metrics-select">
+        <ControlHeader {...this.props} />
+        <OnPasteSelect
+          multi
+          name={`select-${this.props.name}`}
+          placeholder={t('choose a column or metric')}
+          options={this.state.options}
+          value={this.state.values}
+          labelKey="label"
+          valueKey="filterOptionName"
+          clearable
+          closeOnSelect
+          onChange={this.onChange}
+          optionRenderer={this.optionRenderer}
+          valueRenderer={this.valueRenderer}
+          selectWrap={VirtualizedSelect}
+        />
+      </div>
+    );
+  }
+}
+
+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 ae964ef03c..dd1d6c489f 100644
--- a/superset/assets/src/explore/controls.jsx
+++ b/superset/assets/src/explore/controls.jsx
@@ -1859,6 +1859,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 28dfdff646..168b274949 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'],
         ],
@@ -1122,6 +1125,7 @@ export const visTypes = {
         expanded: true,
         controlSetRows: [
           ['metric'],
+          ['adhoc_filters'],
         ],
       },
       {
@@ -1148,6 +1152,7 @@ export const visTypes = {
         expanded: true,
         controlSetRows: [
           ['metric'],
+          ['adhoc_filters'],
         ],
       },
       {
@@ -1717,13 +1722,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 26e3c721a0..d1ff97cb02 100644
--- a/superset/connectors/druid/models.py
+++ b/superset/connectors/druid/models.py
@@ -1217,7 +1217,11 @@ def run_query(  # noqa / druid
                 pre_qry_dims = self._dimensions_to_values(qry['dimensions'])
                 pre_qry['dimensions'] = list(set(pre_qry_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
@@ -1267,7 +1271,10 @@ def run_query(  # noqa / druid
                     '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 2d06c48f22..55900ffb67 100644
--- a/superset/utils.py
+++ b/superset/utils.py
@@ -826,8 +826,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 5e6042dc46..cc8bc2cec8 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -252,14 +252,56 @@ def query_obj(self):
 
         # 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 @@ def test_get_data_applies_percentage(self):
         ]
         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()


 

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


With regards,
Apache Git Services

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org