superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From grace...@apache.org
Subject [incubator-superset] branch master updated: [Explore] Adding Adhoc Filters (#4909)
Date Thu, 10 May 2018 17:41:13 GMT
This is an automated email from the ASF dual-hosted git repository.

graceguo pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new a8514b2  [Explore] Adding Adhoc Filters (#4909)
a8514b2 is described below

commit a8514b267bd03a9c0a88a4f0809cdbe26eac45ac
Author: Gabe Lyons <gabe.lyons@airbnb.com>
AuthorDate: Thu May 10 10:41:10 2018 -0700

    [Explore] Adding Adhoc Filters (#4909)
    
    * adding custom expressions to adhoc metrics
    
    * adjusted transitions and made the box expandable
    
    * adding adhoc filters
    
    * adjusted based on feedback
---
 .../spec/javascripts/explore/AdhocFilter_spec.js   | 136 +++++++++++
 .../explore/components/AdhocFilterControl_spec.jsx | 189 +++++++++++++++
 ...AdhocFilterEditPopoverSimpleTabContent_spec.jsx | 122 ++++++++++
 .../AdhocFilterEditPopoverSqlTabContent_spec.jsx   |  54 +++++
 .../components/AdhocFilterEditPopover_spec.jsx     | 112 +++++++++
 .../explore/components/AdhocFilterOption_spec.jsx  |  39 ++++
 .../components/AdhocMetricStaticOption_spec.jsx    |  22 ++
 .../components/FilterDefinitionOption_spec.jsx     |  36 +++
 superset/assets/src/explore/AdhocFilter.js         | 102 ++++++++
 superset/assets/src/explore/AdhocMetric.js         |  13 +-
 .../explore/components/AdhocFilterEditPopover.jsx  | 153 ++++++++++++
 .../AdhocFilterEditPopoverSimpleTabContent.jsx     | 257 ++++++++++++++++++++
 .../AdhocFilterEditPopoverSqlTabContent.jsx        | 121 ++++++++++
 .../src/explore/components/AdhocFilterOption.jsx   |  93 ++++++++
 .../explore/components/AdhocMetricEditPopover.jsx  |   6 +-
 .../src/explore/components/AdhocMetricOption.jsx   |  18 ++
 .../explore/components/AdhocMetricStaticOption.jsx |  22 ++
 superset/assets/src/explore/components/Control.jsx |   1 +
 .../explore/components/ControlPanelsContainer.jsx  |   1 +
 .../explore/components/FilterDefinitionOption.jsx  |  38 +++
 .../components/controls/AdhocFilterControl.jsx     | 259 +++++++++++++++++++++
 .../explore/components/controls/SelectControl.jsx  |   3 +
 .../src/explore/components/controls/index.js       |   2 +
 superset/assets/src/explore/constants.js           |  26 ++-
 superset/assets/src/explore/controls.jsx           |  13 ++
 superset/assets/src/explore/main.css               |  49 ++++
 .../src/explore/propTypes/adhocFilterType.js       |  22 ++
 superset/assets/src/explore/visTypes.js            |  17 +-
 superset/connectors/druid/models.py                |  11 +-
 superset/utils.py                                  |   6 +-
 superset/viz.py                                    |  58 ++++-
 tests/viz_tests.py                                 | 114 +++++++++
 32 files changed, 2094 insertions(+), 21 deletions(-)

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 0000000..0cf9e58
--- /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 0000000..4be8a2e
--- /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 0000000..005b287
--- /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 0000000..a1cdb23
--- /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 0000000..3b062ed
--- /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 0000000..673b854
--- /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 0000000..54ff78e
--- /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 0000000..05e02b9
--- /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 0000000..0c84ef5
--- /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 5c62f05..e069fd7 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 0000000..7439ab3
--- /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 0000000..b13fea1
--- /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 0000000..8a3a97b
--- /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 0000000..eb7a5c1
--- /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 4fb8032..24ac5b5 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 e7b270e..482557a 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 0000000..bce6493
--- /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 25d69a5..52682de 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 cb2cd79..1bf653f 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 0000000..34355f7
--- /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 0000000..abd8778
--- /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 16cb95e..d2f3543 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 a7ca463..8199127 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 0a92dfd..5239530 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 8dbba95..bc5dc46 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 946f219..40047fa 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 0000000..d09e4f8
--- /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 832c1db..35e49d4 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 079648f..3cece6e 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 bd3d729..25d4ef3 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 7eb34c6..1e3fcb5 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 1762dc8..fb56581 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()

-- 
To stop receiving notification emails like this one, please contact
graceguo@apache.org.

Mime
View raw message