fix(explore): Explore page boolean filter is broken for Presto DB (#14952)

* Front end update - modify OPERATORS, to have SQL operation and display value

* Updated tests

* More tests

* Remove OPERATOR imports

* Fix break tests

* PR comments

* fix issue with comparator loading

* rename a variable

* Linting
This commit is contained in:
Ajay M 2021-06-10 12:08:04 -04:00 committed by GitHub
parent c0eff8faf6
commit f8b270d419
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 976 additions and 799 deletions

View File

@ -20,6 +20,11 @@ import React from 'react';
import { styled } from '@superset-ui/core';
import Select, { SelectProps } from 'antd/lib/select';
export {
OptionType as NativeSelectOptionType,
SelectProps as NativeSelectProps,
} from 'antd/lib/select';
const StyledNativeSelect = styled((props: SelectProps<any>) => (
<Select getPopupContainer={(trigger: any) => trigger.parentNode} {...props} />
))`

View File

@ -20,7 +20,10 @@ import React, { useEffect, useMemo, useState } from 'react';
import { logging, SupersetClient, t, Metric } from '@superset-ui/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
import { Tooltip } from 'src/components/Tooltip';
import { OPERATORS } from 'src/explore/constants';
import {
Operators,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
} from 'src/explore/constants';
import { OptionSortType } from 'src/explore/types';
import {
DndFilterSelectProps,
@ -191,7 +194,9 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
props.datasource.type === 'druid'
? filterOptions.saved_metric_name
: getMetricExpression(filterOptions.saved_metric_name),
operator: OPERATORS['>'],
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GREATER_THAN].operation,
operatorId: Operators.GREATER_THAN,
comparator: 0,
clause: CLAUSES.HAVING,
});
@ -207,7 +212,9 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
props.datasource.type === 'druid'
? filterOptions.label
: new AdhocMetric(option).translateToSql(),
operator: OPERATORS['>'],
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GREATER_THAN].operation,
operatorId: Operators.GREATER_THAN,
comparator: 0,
clause: CLAUSES.HAVING,
});
@ -217,7 +224,8 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
return new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: filterOptions.column_name,
operator: OPERATORS['=='],
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.EQUALS].operation,
operatorId: Operators.EQUALS,
comparator: '',
clause: CLAUSES.WHERE,
isNew: true,

View File

@ -20,6 +20,7 @@ import AdhocFilter, {
EXPRESSION_TYPES,
CLAUSES,
} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { Operators } from 'src/explore/constants';
describe('AdhocFilter', () => {
it('sets filterOptionName in constructor', () => {
@ -188,6 +189,22 @@ describe('AdhocFilter', () => {
});
// eslint-disable-next-line no-unused-expressions
expect(adhocFilter8.isValid()).toBe(false);
const adhocFilter9 = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'value',
operator: 'IS NULL',
clause: CLAUSES.WHERE,
});
expect(adhocFilter9.isValid()).toBe(true);
const adhocFilter10 = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'value',
operator: 'IS NOT NULL',
clause: CLAUSES.WHERE,
});
// eslint-disable-next-line no-unused-expressions
expect(adhocFilter10.isValid()).toBe(true);
});
it('can translate from simple expressions to sql expressions', () => {
@ -209,4 +226,26 @@ describe('AdhocFilter', () => {
});
expect(adhocFilter2.translateToSql()).toBe('SUM(value) <> 5');
});
it('sets comparator to null when operator is IS_NULL', () => {
const adhocFilter2 = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'SUM(value)',
operator: 'IS NULL',
operatorId: Operators.IS_NULL,
comparator: '5',
clause: CLAUSES.HAVING,
});
expect(adhocFilter2.comparator).toBe(null);
});
it('sets comparator to null when operator is IS_NOT_NULL', () => {
const adhocFilter2 = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'SUM(value)',
operator: 'IS NOT NULL',
operatorId: Operators.IS_NOT_NULL,
comparator: '5',
clause: CLAUSES.HAVING,
});
expect(adhocFilter2.comparator).toBe(null);
});
});

View File

@ -16,7 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { CUSTOM_OPERATORS, OPERATORS } from 'src/explore/constants';
import {
CUSTOM_OPERATORS,
Operators,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
} from 'src/explore/constants';
import { getSimpleSQLExpression } from 'src/explore/exploreUtils';
export const EXPRESSION_TYPES = {
@ -49,11 +53,16 @@ const OPERATORS_TO_SQL = {
`= '{{ presto.latest_partition('${datasource.schema}.${datasource.datasource_name}') }}'`,
};
const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
);
function translateToSql(adhocMetric, { useSimple } = {}) {
if (adhocMetric.expressionType === EXPRESSION_TYPES.SIMPLE || useSimple) {
const { subject, comparator } = adhocMetric;
const operator =
adhocMetric.operator && CUSTOM_OPERATORS.has(adhocMetric.operator)
adhocMetric.operator &&
CUSTOM_OPERATIONS.indexOf(adhocMetric.operator) >= 0
? OPERATORS_TO_SQL[adhocMetric.operator](adhocMetric)
: OPERATORS_TO_SQL[adhocMetric.operator];
return getSimpleSQLExpression(subject, operator, comparator);
@ -70,7 +79,22 @@ export default class AdhocFilter {
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
this.subject = adhocFilter.subject;
this.operator = adhocFilter.operator?.toUpperCase();
this.operatorId = adhocFilter.operatorId;
this.comparator = adhocFilter.comparator;
if (
[Operators.IS_TRUE, Operators.IS_FALSE].indexOf(
adhocFilter.operatorId,
) >= 0
) {
this.comparator = adhocFilter.operatorId === Operators.IS_TRUE;
}
if (
[Operators.IS_NULL, Operators.IS_NOT_NULL].indexOf(
adhocFilter.operatorId,
) >= 0
) {
this.comparator = null;
}
this.clause = adhocFilter.clause || CLAUSES.WHERE;
this.sqlExpression = null;
} else if (this.expressionType === EXPRESSION_TYPES.SQL) {
@ -79,9 +103,13 @@ export default class AdhocFilter {
? adhocFilter.sqlExpression
: translateToSql(adhocFilter, { useSimple: true });
this.clause = adhocFilter.clause;
if (adhocFilter.operator && CUSTOM_OPERATORS.has(adhocFilter.operator)) {
if (
adhocFilter.operator &&
CUSTOM_OPERATIONS.indexOf(adhocFilter.operator) >= 0
) {
this.subject = adhocFilter.subject;
this.operator = adhocFilter.operator;
this.operatorId = adhocFilter.operatorId;
} else {
this.subject = null;
this.operator = null;
@ -112,24 +140,26 @@ export default class AdhocFilter {
adhocFilter.expressionType === this.expressionType &&
adhocFilter.sqlExpression === this.sqlExpression &&
adhocFilter.operator === this.operator &&
adhocFilter.operatorId === this.operatorId &&
adhocFilter.comparator === this.comparator &&
adhocFilter.subject === this.subject
);
}
isValid() {
const nullCheckOperators = [Operators.IS_NOT_NULL, Operators.IS_NULL].map(
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
);
const truthCheckOperators = [Operators.IS_TRUE, Operators.IS_FALSE].map(
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
);
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
if (
[
OPERATORS['IS TRUE'],
OPERATORS['IS FALSE'],
OPERATORS['IS NULL'],
OPERATORS['IS NOT NULL'],
].indexOf(this.operator) >= 0
) {
if (nullCheckOperators.indexOf(this.operator) >= 0) {
return !!(this.operator && this.subject);
}
if (truthCheckOperators.indexOf(this.operator) >= 0) {
return !!(this.subject && this.comparator !== null);
}
if (this.operator && this.subject && this.clause) {
if (Array.isArray(this.comparator)) {
if (this.comparator.length > 0) {

View File

@ -27,14 +27,18 @@ import AdhocFilter, {
CLAUSES,
} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { LabelsContainer } from 'src/explore/components/controls/OptionControls';
import { AGGREGATES, OPERATORS } from 'src/explore/constants';
import {
AGGREGATES,
Operators,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
} from 'src/explore/constants';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocFilterControl from '.';
const simpleAdhocFilter = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'value',
operator: '>',
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GREATER_THAN].operation,
comparator: '10',
clause: CLAUSES.WHERE,
});
@ -92,7 +96,8 @@ describe('AdhocFilterControl', () => {
new AdhocFilter({
expressionType: EXPRESSION_TYPES.SQL,
subject: savedMetric.expression,
operator: OPERATORS['>'],
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GREATER_THAN].operation,
comparator: 0,
clause: CLAUSES.HAVING,
}),
@ -111,7 +116,8 @@ describe('AdhocFilterControl', () => {
new AdhocFilter({
expressionType: EXPRESSION_TYPES.SQL,
subject: sumValueAdhocMetric.label,
operator: OPERATORS['>'],
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GREATER_THAN].operation,
comparator: 0,
clause: CLAUSES.HAVING,
}),
@ -134,7 +140,7 @@ describe('AdhocFilterControl', () => {
new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: columns[0].column_name,
operator: OPERATORS['=='],
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.EQUALS].operation,
comparator: '',
clause: CLAUSES.WHERE,
}),

View File

@ -30,7 +30,10 @@ import ControlHeader from 'src/explore/components/ControlHeader';
import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType';
import savedMetricType from 'src/explore/components/controls/MetricControl/savedMetricType';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import { OPERATORS } from 'src/explore/constants';
import {
Operators,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
} from 'src/explore/constants';
import FilterDefinitionOption from 'src/explore/components/controls/MetricControl/FilterDefinitionOption';
import {
AddControlLabel,
@ -242,7 +245,8 @@ class AdhocFilterControl extends React.Component {
this.props.datasource.type === 'druid'
? option.saved_metric_name
: this.getMetricExpression(option.saved_metric_name),
operator: OPERATORS['>'],
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GREATER_THAN].operation,
comparator: 0,
clause: CLAUSES.HAVING,
});
@ -258,7 +262,8 @@ class AdhocFilterControl extends React.Component {
this.props.datasource.type === 'druid'
? option.label
: new AdhocMetric(option).translateToSql(),
operator: OPERATORS['>'],
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GREATER_THAN].operation,
comparator: 0,
clause: CLAUSES.HAVING,
});
@ -268,7 +273,7 @@ class AdhocFilterControl extends React.Component {
return new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: option.column_name,
operator: OPERATORS['=='],
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.EQUALS].operation,
comparator: '',
clause: CLAUSES.WHERE,
isNew: true,

View File

@ -1,277 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable no-unused-expressions */
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import AdhocFilter, {
EXPRESSION_TYPES,
CLAUSES,
} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { AGGREGATES } from 'src/explore/constants';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocFilterEditPopoverSimpleTabContent from '.';
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 simpleCustomFilter = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'ds',
operator: 'LATEST PARTITION',
});
const options = [
{ type: 'VARCHAR(255)', column_name: 'source', id: 1 },
{ type: 'VARCHAR(255)', column_name: 'target', id: 2 },
{ type: 'DOUBLE', column_name: 'value', id: 3 },
{ saved_metric_name: 'my_custom_metric', id: 4 },
sumValueAdhocMetric,
];
function setup(overrides) {
const onChange = sinon.spy();
const onHeightChange = sinon.spy();
const props = {
adhocFilter: simpleAdhocFilter,
onChange,
onHeightChange,
options,
datasource: {},
...overrides,
};
const wrapper = shallow(
<AdhocFilterEditPopoverSimpleTabContent {...props} />,
);
return { wrapper, onChange, onHeightChange };
}
describe('AdhocFilterEditPopoverSimpleTabContent', () => {
it('renders the simple tab form', () => {
const { wrapper } = setup();
expect(wrapper).toExist();
});
it('passes the new adhocFilter to onChange after onSubjectChange', () => {
const { wrapper, onChange } = setup();
wrapper.instance().onSubjectChange(1);
expect(onChange.calledOnce).toBe(true);
expect(onChange.lastCall.args[0]).toEqual(
simpleAdhocFilter.duplicateWith({ subject: 'source' }),
);
});
it('may alter the clause in onSubjectChange if the old clause is not appropriate', () => {
const { wrapper, onChange } = setup();
wrapper.instance().onSubjectChange(sumValueAdhocMetric.optionName);
expect(onChange.calledOnce).toBe(true);
expect(onChange.lastCall.args[0]).toEqual(
simpleAdhocFilter.duplicateWith({
subject: sumValueAdhocMetric.label,
clause: CLAUSES.HAVING,
}),
);
});
it('will convert from individual comparator to array if the operator changes to multi', () => {
const { wrapper, onChange } = setup();
wrapper.instance().onOperatorChange('IN');
expect(onChange.calledOnce).toBe(true);
expect(onChange.lastCall.args[0]).toEqual(
simpleAdhocFilter.duplicateWith({ operator: 'IN', comparator: ['10'] }),
);
});
it('will convert from array to individual comparators if the operator changes from multi', () => {
const { wrapper, onChange } = setup({
adhocFilter: simpleMultiAdhocFilter,
});
wrapper.instance().onOperatorChange('<');
expect(onChange.calledOnce).toBe(true);
expect(onChange.lastCall.args[0]).toEqual(
simpleMultiAdhocFilter.duplicateWith({ operator: '<', comparator: '10' }),
);
});
it('passes the new adhocFilter to onChange after onComparatorChange', () => {
const { wrapper, onChange } = setup();
wrapper.instance().onComparatorChange('20');
expect(onChange.calledOnce).toBe(true);
expect(onChange.lastCall.args[0]).toEqual(
simpleAdhocFilter.duplicateWith({ comparator: '20' }),
);
});
it('will filter operators for table datasources', () => {
const { wrapper } = setup({ datasource: { type: 'table' } });
expect(wrapper.instance().isOperatorRelevant('REGEX')).toBe(false);
expect(wrapper.instance().isOperatorRelevant('LIKE')).toBe(true);
});
it('will filter operators for druid datasources', () => {
const { wrapper } = setup({ datasource: { type: 'druid' } });
expect(wrapper.instance().isOperatorRelevant('REGEX')).toBe(true);
expect(wrapper.instance().isOperatorRelevant('LIKE')).toBe(false);
});
it('will show LATEST PARTITION operator', () => {
const { wrapper } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
},
adhocFilter: simpleCustomFilter,
partitionColumn: 'ds',
});
expect(
wrapper.instance().isOperatorRelevant('LATEST PARTITION', 'ds'),
).toBe(true);
expect(
wrapper.instance().isOperatorRelevant('LATEST PARTITION', 'value'),
).toBe(false);
});
it('will generate custom sqlExpression for LATEST PARTITION operator', () => {
const testAdhocFilter = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'ds',
});
const { wrapper, onChange } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
},
adhocFilter: testAdhocFilter,
partitionColumn: 'ds',
});
wrapper.instance().onOperatorChange('LATEST PARTITION');
expect(onChange.lastCall.args[0]).toEqual(
testAdhocFilter.duplicateWith({
subject: 'ds',
operator: 'LATEST PARTITION',
comparator: null,
clause: 'WHERE',
expressionType: 'SQL',
sqlExpression: "ds = '{{ presto.latest_partition('schema.table1') }}'",
}),
);
});
it('will display boolean operators only when column type is boolean', () => {
const { wrapper } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
columns: [{ column_name: 'value', type: 'BOOL' }],
},
adhocFilter: simpleAdhocFilter,
});
const booleanOnlyOperators = [
'IS TRUE',
'IS FALSE',
'IS NULL',
'IS NOT NULL',
];
booleanOnlyOperators.forEach(operator => {
expect(wrapper.instance().isOperatorRelevant(operator, 'value')).toBe(
true,
);
});
});
it('will display boolean operators when column type is number', () => {
const { wrapper } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
columns: [{ column_name: 'value', type: 'INT' }],
},
adhocFilter: simpleAdhocFilter,
});
const booleanOnlyOperators = ['IS TRUE', 'IS FALSE'];
booleanOnlyOperators.forEach(operator => {
expect(wrapper.instance().isOperatorRelevant(operator, 'value')).toBe(
true,
);
});
});
it('will not display boolean operators when column type is string', () => {
const { wrapper } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
columns: [{ column_name: 'value', type: 'STRING' }],
},
adhocFilter: simpleAdhocFilter,
});
const booleanOnlyOperators = ['IS TRUE', 'IS FALSE'];
booleanOnlyOperators.forEach(operator => {
expect(wrapper.instance().isOperatorRelevant(operator, 'value')).toBe(
false,
);
});
});
it('will display boolean operators when column is an expression', () => {
const { wrapper } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
columns: [
{
column_name: 'value',
expression: 'case when value is 0 then "NO"',
},
],
},
adhocFilter: simpleAdhocFilter,
});
const booleanOnlyOperators = ['IS TRUE', 'IS FALSE'];
booleanOnlyOperators.forEach(operator => {
expect(wrapper.instance().isOperatorRelevant(operator, 'value')).toBe(
true,
);
});
});
});

View File

@ -0,0 +1,322 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable no-unused-expressions */
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import AdhocFilter, {
EXPRESSION_TYPES,
CLAUSES,
} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import {
AGGREGATES,
Operators,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
} from 'src/explore/constants';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocFilterEditPopoverSimpleTabContent, {
useSimpleTabFilterProps,
} from '.';
const simpleAdhocFilter = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'value',
operatorId: Operators.GREATER_THAN,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GREATER_THAN].operation,
comparator: '10',
clause: CLAUSES.WHERE,
});
const simpleMultiAdhocFilter = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'value',
operatorId: Operators.IN,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IN].operation,
comparator: ['10'],
clause: CLAUSES.WHERE,
});
const sumValueAdhocMetric = new AdhocMetric({
expressionType: EXPRESSION_TYPES.SIMPLE,
column: { type: 'VARCHAR(255)', column_name: 'source' },
aggregate: AGGREGATES.SUM,
});
const simpleCustomFilter = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'ds',
operator: 'LATEST PARTITION',
operatorId: Operators.LATEST_PARTITION,
});
const options = [
{ type: 'VARCHAR(255)', column_name: 'source', id: 1 },
{ type: 'VARCHAR(255)', column_name: 'target', id: 2 },
{ type: 'DOUBLE', column_name: 'value', id: 3 },
{ saved_metric_name: 'my_custom_metric', id: 4 },
sumValueAdhocMetric,
];
function setup(overrides?: Record<string, any>) {
const onChange = sinon.spy();
const props = {
adhocFilter: simpleAdhocFilter,
onChange,
options,
datasource: {
id: 'test-id',
columns: [],
type: 'postgres',
filter_select: false,
},
partitionColumn: 'test',
...overrides,
};
const wrapper = shallow(
<AdhocFilterEditPopoverSimpleTabContent {...props} />,
);
return { wrapper, props };
}
describe('AdhocFilterEditPopoverSimpleTabContent', () => {
it('renders the simple tab form', () => {
const { wrapper } = setup();
expect(wrapper).toExist();
});
it('shows boolean only operators when subject is boolean', () => {
const { props } = setup({
adhocFilter: new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'value',
operatorId: null,
operator: null,
comparator: null,
clause: null,
}),
datasource: {
columns: [
{
id: 3,
column_name: 'value',
type: 'BOOL',
},
],
},
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
[
Operators.IS_TRUE,
Operators.IS_FALSE,
Operators.IS_NULL,
Operators.IS_FALSE,
].map(operator => expect(isOperatorRelevant(operator, 'value')).toBe(true));
});
it('shows boolean only operators when subject is number', () => {
const { props } = setup({
adhocFilter: new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'value',
operatorId: null,
operator: null,
comparator: null,
clause: null,
}),
datasource: {
columns: [
{
id: 3,
column_name: 'value',
type: 'INT',
},
],
},
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
[
Operators.IS_TRUE,
Operators.IS_FALSE,
Operators.IS_NULL,
Operators.IS_NOT_NULL,
].map(operator => expect(isOperatorRelevant(operator, 'value')).toBe(true));
});
it('will convert from individual comparator to array if the operator changes to multi', () => {
const { props } = setup();
const { onOperatorChange } = useSimpleTabFilterProps(props);
onOperatorChange(Operators.IN);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0].comparator).toEqual(['10']);
expect(props.onChange.lastCall.args[0].operatorId).toEqual(Operators.IN);
});
it('will convert from array to individual comparators if the operator changes from multi', () => {
const { props } = setup({
adhocFilter: simpleMultiAdhocFilter,
});
const { onOperatorChange } = useSimpleTabFilterProps(props);
onOperatorChange(Operators.LESS_THAN);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0]).toEqual(
simpleMultiAdhocFilter.duplicateWith({
operatorId: Operators.LESS_THAN,
operator: '<',
comparator: '10',
}),
);
});
it('passes the new adhocFilter to onChange after onComparatorChange', () => {
const { props } = setup();
const { onComparatorChange } = useSimpleTabFilterProps(props);
onComparatorChange('20');
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0]).toEqual(
simpleAdhocFilter.duplicateWith({ comparator: '20' }),
);
});
it('will filter operators for table datasources', () => {
const { props } = setup({ datasource: { type: 'table' } });
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
expect(isOperatorRelevant(Operators.REGEX, 'value')).toBe(false);
expect(isOperatorRelevant(Operators.LIKE, 'value')).toBe(true);
});
it('will filter operators for druid datasources', () => {
const { props } = setup({ datasource: { type: 'druid' } });
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
expect(isOperatorRelevant(Operators.REGEX, 'value')).toBe(true);
expect(isOperatorRelevant(Operators.LIKE, 'value')).toBe(false);
});
it('will show LATEST PARTITION operator', () => {
const { props } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
},
adhocFilter: simpleCustomFilter,
partitionColumn: 'ds',
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
expect(isOperatorRelevant(Operators.LATEST_PARTITION, 'ds')).toBe(true);
expect(isOperatorRelevant(Operators.LATEST_PARTITION, 'value')).toBe(false);
});
it('will generate custom sqlExpression for LATEST PARTITION operator', () => {
const testAdhocFilter = new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: 'ds',
});
const { props } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
},
adhocFilter: testAdhocFilter,
partitionColumn: 'ds',
});
const { onOperatorChange } = useSimpleTabFilterProps(props);
onOperatorChange(Operators.LATEST_PARTITION);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0]).toEqual(
testAdhocFilter.duplicateWith({
subject: 'ds',
operator: 'LATEST PARTITION',
operatorId: Operators.LATEST_PARTITION,
comparator: null,
clause: 'WHERE',
expressionType: 'SQL',
sqlExpression: "ds = '{{ presto.latest_partition('schema.table1') }}'",
}),
);
});
it('will not display boolean operators when column type is string', () => {
const { props } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
columns: [{ column_name: 'value', type: 'STRING' }],
},
adhocFilter: simpleAdhocFilter,
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
const booleanOnlyOperators = [Operators.IS_TRUE, Operators.IS_FALSE];
booleanOnlyOperators.forEach(operator => {
expect(isOperatorRelevant(operator, 'value')).toBe(false);
});
});
it('will display boolean operators when column is an expression', () => {
const { props } = setup({
datasource: {
type: 'table',
datasource_name: 'table1',
schema: 'schema',
columns: [
{
column_name: 'value',
expression: 'case when value is 0 then "NO"',
},
],
},
adhocFilter: simpleAdhocFilter,
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
const booleanOnlyOperators = [Operators.IS_TRUE, Operators.IS_FALSE];
booleanOnlyOperators.forEach(operator => {
expect(isOperatorRelevant(operator, 'value')).toBe(true);
});
});
it('sets comparator to true when operator is IS_TRUE', () => {
const { props } = setup();
const { onOperatorChange } = useSimpleTabFilterProps(props);
onOperatorChange(Operators.IS_TRUE);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0].operatorId).toBe(Operators.IS_TRUE);
expect(props.onChange.lastCall.args[0].operator).toBe('==');
expect(props.onChange.lastCall.args[0].comparator).toBe(true);
});
it('sets comparator to false when operator is IS_FALSE', () => {
const { props } = setup();
const { onOperatorChange } = useSimpleTabFilterProps(props);
onOperatorChange(Operators.IS_FALSE);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0].operatorId).toBe(Operators.IS_FALSE);
expect(props.onChange.lastCall.args[0].operator).toBe('==');
expect(props.onChange.lastCall.args[0].comparator).toBe(false);
});
it('sets comparator to null when operator is IS_NULL or IS_NOT_NULL', () => {
const { props } = setup();
const { onOperatorChange } = useSimpleTabFilterProps(props);
[Operators.IS_NULL, Operators.IS_NOT_NULL].forEach(op => {
onOperatorChange(op);
expect(props.onChange.called).toBe(true);
expect(props.onChange.lastCall.args[0].operatorId).toBe(op);
expect(props.onChange.lastCall.args[0].operator).toBe(
OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
);
expect(props.onChange.lastCall.args[0].comparator).toBe(null);
});
});
});

View File

@ -1,457 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { NativeSelect as Select } from 'src/components/Select';
import { Input } from 'src/common/components';
import { t, SupersetClient, styled } from '@superset-ui/core';
import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType';
import {
OPERATORS,
OPERATORS_OPTIONS,
TABLE_ONLY_OPERATORS,
DRUID_ONLY_OPERATORS,
HAVING_OPERATORS,
MULTI_OPERATORS,
CUSTOM_OPERATORS,
DISABLE_INPUT_OPERATORS,
} from 'src/explore/constants';
import FilterDefinitionOption from 'src/explore/components/controls/MetricControl/FilterDefinitionOption';
import AdhocFilter, {
EXPRESSION_TYPES,
CLAUSES,
} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import columnType from 'src/explore/components/controls/FilterControl/columnType';
import Icons from 'src/components/Icons';
const SelectWithLabel = styled(Select)`
.ant-select-selector {
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
}
.ant-select-selector::after {
content: '${({ labelText }) => labelText || '\\A0'}';
display: inline-block;
white-space: nowrap;
color: ${({ theme }) => theme.colors.grayscale.light1};
width: max-content;
}
`;
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,
onHeightChange: PropTypes.func.isRequired,
datasource: PropTypes.object,
partitionColumn: PropTypes.string,
popoverRef: PropTypes.object,
};
const defaultProps = {
datasource: {},
};
function translateOperator(operator) {
if (operator === OPERATORS['==']) {
return 'equals';
}
if (operator === OPERATORS['!=']) {
return 'not equal to';
}
if (operator === OPERATORS.LIKE) {
return 'LIKE';
}
if (operator === OPERATORS.ILIKE) {
return 'LIKE (case insensitive)';
}
if (operator === OPERATORS['LATEST PARTITION']) {
return 'use latest_partition template';
}
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.clearSuggestionSearch = this.clearSuggestionSearch.bind(this);
this.state = {
suggestions: [],
abortActiveRequest: null,
currentSuggestionSearch: '',
};
this.selectProps = {
name: 'select-column',
showSearch: true,
};
}
UNSAFE_componentWillMount() {
this.refreshComparatorSuggestions();
}
componentDidUpdate(prevProps) {
if (prevProps.adhocFilter.subject !== this.props.adhocFilter.subject) {
this.refreshComparatorSuggestions();
}
}
onSubjectChange(id) {
const option = this.props.options.find(
option => option.id === id || option.optionName === id,
);
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;
}
const { operator } = this.props.adhocFilter;
this.props.onChange(
this.props.adhocFilter.duplicateWith({
subject,
clause,
operator:
operator && this.isOperatorRelevant(operator, subject)
? operator
: null,
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.has(operator)) {
newComparator = Array.isArray(currentComparator)
? currentComparator
: [currentComparator].filter(element => element);
} else {
newComparator = Array.isArray(currentComparator)
? currentComparator[0]
: currentComparator;
}
if (operator && CUSTOM_OPERATORS.has(operator)) {
this.props.onChange(
this.props.adhocFilter.duplicateWith({
subject: this.props.adhocFilter.subject,
clause: CLAUSES.WHERE,
operator,
expressionType: EXPRESSION_TYPES.SQL,
datasource: this.props.datasource,
}),
);
} else {
this.props.onChange(
this.props.adhocFilter.duplicateWith({
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;
const col = this.props.adhocFilter.subject;
const having = this.props.adhocFilter.clause === CLAUSES.HAVING;
if (col && datasource && datasource.filter_select && !having) {
if (this.state.abortActiveRequest) {
this.state.abortActiveRequest();
}
const controller = new AbortController();
const { signal } = controller;
this.setState({ abortActiveRequest: controller.abort, loading: true });
SupersetClient.get({
signal,
endpoint: `/superset/filter/${datasource.type}/${datasource.id}/${col}/`,
})
.then(({ json }) => {
this.setState(() => ({
suggestions: json,
abortActiveRequest: null,
loading: false,
}));
})
.catch(() => {
this.setState(() => ({
suggestions: [],
abortActiveRequest: null,
loading: false,
}));
});
}
}
isOperatorRelevant(operator, subject) {
const column = this.props.datasource.columns?.find(
col => col.column_name === subject,
);
const isColumnBoolean =
!!column && (column.type === 'BOOL' || column.type === 'BOOLEAN');
const isColumnNumber = !!column && column.type === 'INT';
const isColumnFunction = !!column && !!column.expression;
if (operator && CUSTOM_OPERATORS.has(operator)) {
const { partitionColumn } = this.props;
return partitionColumn && subject && subject === partitionColumn;
}
if (
operator === OPERATORS['IS TRUE'] ||
operator === OPERATORS['IS FALSE']
) {
return isColumnBoolean || isColumnNumber || isColumnFunction;
}
if (isColumnBoolean) {
return (
operator === OPERATORS['IS NULL'] ||
operator === OPERATORS['IS NOT NULL']
);
}
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, shouldFocus) {
if (ref && shouldFocus) {
ref.focus();
}
}
optionsRemaining() {
const { suggestions } = this.state;
const { comparator } = this.props.adhocFilter;
// if select is multi/value is array, we show the options not selected
const valuesFromSuggestionsLength = Array.isArray(comparator)
? comparator.filter(v => suggestions.includes(v)).length
: 0;
return suggestions?.length - valuesFromSuggestionsLength ?? 0;
}
createSuggestionsPlaceholder() {
const optionsRemaining = this.optionsRemaining();
const placeholder = t('%s option(s)', optionsRemaining);
return optionsRemaining ? placeholder : '';
}
renderSubjectOptionLabel(option) {
return <FilterDefinitionOption option={option} />;
}
clearSuggestionSearch() {
this.setState({ currentSuggestionSearch: '' });
}
render() {
const { adhocFilter, options, datasource } = this.props;
const { currentSuggestionSearch } = this.state;
let columns = options;
const { subject, operator, comparator } = adhocFilter;
const subjectSelectProps = {
value: subject ?? undefined,
onChange: this.onSubjectChange,
notFoundContent: t(
'No such column found. To filter on a metric, try the Custom SQL tab.',
),
filterOption: (input, option) =>
option.filterBy.toLowerCase().indexOf(input.toLowerCase()) >= 0,
autoFocus: !subject,
};
if (datasource.type === 'druid') {
subjectSelectProps.placeholder = t(
'%s column(s) and metric(s)',
columns.length,
);
} 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.placeholder =
adhocFilter.clause === CLAUSES.WHERE
? t('%s column(s)', columns.length)
: t('To filter on a metric, use Custom SQL tab.');
columns = options.filter(option => option.column_name);
}
const operatorSelectProps = {
placeholder: t(
'%s operator(s)',
OPERATORS_OPTIONS.filter(op => this.isOperatorRelevant(op, subject))
.length,
),
// like AGGREGATES_OPTIONS, operator options are string
value: operator,
onChange: this.onOperatorChange,
filterOption: (input, option) =>
option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0,
autoFocus: !!subjectSelectProps.value && !operator,
};
const focusComparator =
!!subjectSelectProps.value && !!operatorSelectProps.value;
const comparatorSelectProps = {
allowClear: true,
showSearch: true,
mode: MULTI_OPERATORS.has(operator) && 'tags',
tokenSeparators: [',', '\n', '\t', ';'],
loading: this.state.loading,
value: comparator,
onChange: this.onComparatorChange,
notFoundContent: t('Type a value here'),
disabled: DISABLE_INPUT_OPERATORS.includes(operator),
placeholder: this.createSuggestionsPlaceholder(),
labelText: comparator?.length > 0 && this.createSuggestionsPlaceholder(),
autoFocus: focusComparator,
};
const Icon =
operator === 'NOT IN' ? Icons.StopOutlined : Icons.CheckOutlined;
return (
<>
<Select
css={theme => ({
marginTop: theme.gridUnit * 4,
marginBottom: theme.gridUnit * 4,
})}
{...this.selectProps}
{...subjectSelectProps}
name="filter-column"
getPopupContainer={triggerNode => triggerNode.parentNode}
>
{columns.map(column => (
<Select.Option
value={column.id || column.optionName}
filterBy={
column.saved_metric_name || column.column_name || column.label
}
key={column.id || column.optionName}
>
{this.renderSubjectOptionLabel(column)}
</Select.Option>
))}
</Select>
<Select
css={theme => ({ marginBottom: theme.gridUnit * 4 })}
{...this.selectProps}
{...operatorSelectProps}
getPopupContainer={triggerNode => triggerNode.parentNode}
name="filter-operator"
>
{OPERATORS_OPTIONS.filter(op =>
this.isOperatorRelevant(op, subject),
).map(option => (
<Select.Option value={option} key={option}>
{translateOperator(option)}
</Select.Option>
))}
</Select>
{MULTI_OPERATORS.has(operator) || this.state.suggestions.length > 0 ? (
<SelectWithLabel
data-test="adhoc-filter-simple-value"
name="filter-value"
{...comparatorSelectProps}
getPopupContainer={triggerNode => triggerNode.parentNode}
onSearch={val => this.setState({ currentSuggestionSearch: val })}
onSelect={this.clearSuggestionSearch}
onBlur={this.clearSuggestionSearch}
menuItemSelectedIcon={<Icon iconSize="m" />}
>
{this.state.suggestions.map(suggestion => (
<Select.Option value={suggestion} key={suggestion}>
{suggestion}
</Select.Option>
))}
{/* enable selecting an option not included in suggestions */}
{currentSuggestionSearch &&
!this.state.suggestions.some(
suggestion => suggestion === currentSuggestionSearch,
) && (
<Select.Option value={currentSuggestionSearch}>
{`${t('Create "%s"', currentSuggestionSearch)}`}
</Select.Option>
)}
</SelectWithLabel>
) : (
<Input
data-test="adhoc-filter-simple-value"
name="filter-value"
ref={ref => this.focusComparator(ref, focusComparator)}
onChange={this.onInputComparatorChange}
value={comparator}
placeholder={t('Filter value (case sensitive)')}
disabled={DISABLE_INPUT_OPERATORS.includes(operator)}
/>
)}
</>
);
}
}
AdhocFilterEditPopoverSimpleTabContent.propTypes = propTypes;
AdhocFilterEditPopoverSimpleTabContent.defaultProps = defaultProps;

View File

@ -0,0 +1,463 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useState } from 'react';
import { NativeSelect as Select } from 'src/components/Select';
import { t, SupersetClient, styled } from '@superset-ui/core';
import {
Operators,
OPERATORS_OPTIONS,
TABLE_ONLY_OPERATORS,
DRUID_ONLY_OPERATORS,
HAVING_OPERATORS,
MULTI_OPERATORS,
CUSTOM_OPERATORS,
DISABLE_INPUT_OPERATORS,
AGGREGATES,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
} from 'src/explore/constants';
import FilterDefinitionOption from 'src/explore/components/controls/MetricControl/FilterDefinitionOption';
import AdhocFilter, {
EXPRESSION_TYPES,
CLAUSES,
} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { Input, SelectProps } from 'src/common/components';
const SelectWithLabel = styled(Select)`
.ant-select-selector {
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
}
.ant-select-selector::after {
content: '${(
pr: SelectProps<any> & {
labelText: string | boolean;
},
) => pr.labelText || '\\A0'}';
display: inline-block;
white-space: nowrap;
color: ${({ theme }) => theme.colors.grayscale.light1};
width: max-content;
}
`;
export interface SimpleColumnType {
id: number;
column_name: string;
expression?: string;
type: string;
optionName?: string;
filterBy?: string;
value?: string;
}
export interface SimpleExpressionType {
expressionType: keyof typeof EXPRESSION_TYPES;
column: SimpleColumnType;
aggregate: keyof typeof AGGREGATES;
label: string;
}
export interface SQLExpressionType {
expressionType: keyof typeof EXPRESSION_TYPES;
sqlExpression: string;
label: string;
}
export interface MetricColumnType {
saved_metric_name: string;
}
export type ColumnType =
| SimpleColumnType
| SimpleExpressionType
| SQLExpressionType
| MetricColumnType;
export interface Props {
adhocFilter: AdhocFilter;
onChange: (filter: AdhocFilter) => void;
options: ColumnType[];
datasource: {
id: string;
columns: SimpleColumnType[];
type: string;
filter_select: boolean;
};
partitionColumn: string;
}
export const useSimpleTabFilterProps = (props: Props) => {
const isOperatorRelevant = (operator: Operators, subject: string) => {
const column = props.datasource.columns?.find(
col => col.column_name === subject,
);
const isColumnBoolean =
!!column && (column.type === 'BOOL' || column.type === 'BOOLEAN');
const isColumnNumber =
!!column && (column.type === 'INT' || column.type === 'INTEGER');
const isColumnFunction = !!column && !!column.expression;
if (operator && CUSTOM_OPERATORS.has(operator)) {
const { partitionColumn } = props;
return partitionColumn && subject && subject === partitionColumn;
}
if (operator === Operators.IS_TRUE || operator === Operators.IS_FALSE) {
return isColumnBoolean || isColumnNumber || isColumnFunction;
}
if (isColumnBoolean) {
return (
operator === Operators.IS_NULL || operator === Operators.IS_NOT_NULL
);
}
return !(
(props.datasource.type === 'druid' &&
TABLE_ONLY_OPERATORS.indexOf(operator) >= 0) ||
(props.datasource.type === 'table' &&
DRUID_ONLY_OPERATORS.indexOf(operator) >= 0) ||
(props.adhocFilter.clause === CLAUSES.HAVING &&
HAVING_OPERATORS.indexOf(operator) === -1)
);
};
const onSubjectChange = (id: string | number) => {
const option = props.options.find(
option =>
('id' in option && option.id === id) ||
('optionName' in option && option.optionName === id),
);
let subject = '';
let clause;
// infer the new clause based on what subject was selected.
if (option && 'column_name' in option) {
subject = option.column_name;
clause = CLAUSES.WHERE;
} else if (option && 'saved_metric_name' in option) {
subject = option.saved_metric_name;
clause = CLAUSES.HAVING;
} else if (option && option.label) {
subject = option.label;
clause = CLAUSES.HAVING;
}
const { operator, operatorId } = props.adhocFilter;
props.onChange(
props.adhocFilter.duplicateWith({
subject,
clause,
operator:
operator && isOperatorRelevant(operatorId, subject)
? OPERATOR_ENUM_TO_OPERATOR_TYPE[operatorId].operation
: null,
expressionType: EXPRESSION_TYPES.SIMPLE,
operatorId,
}),
);
};
const onOperatorChange = (operatorId: Operators) => {
const currentComparator = 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.has(operatorId)) {
newComparator = Array.isArray(currentComparator)
? currentComparator
: [currentComparator].filter(element => element);
} else {
newComparator = Array.isArray(currentComparator)
? currentComparator[0]
: currentComparator;
}
if (operatorId === Operators.IS_TRUE || operatorId === Operators.IS_FALSE) {
newComparator = Operators.IS_TRUE === operatorId;
}
if (operatorId && CUSTOM_OPERATORS.has(operatorId)) {
props.onChange(
props.adhocFilter.duplicateWith({
subject: props.adhocFilter.subject,
clause: CLAUSES.WHERE,
operatorId,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[operatorId].operation,
expressionType: EXPRESSION_TYPES.SQL,
datasource: props.datasource,
}),
);
} else {
props.onChange(
props.adhocFilter.duplicateWith({
operatorId,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[operatorId].operation,
comparator: newComparator,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
);
}
};
const onComparatorChange = (comparator: string) => {
props.onChange(
props.adhocFilter.duplicateWith({
comparator,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
);
};
return {
onSubjectChange,
onOperatorChange,
onComparatorChange,
isOperatorRelevant,
};
};
const AdhocFilterEditPopoverSimpleTabContent: React.FC<Props> = props => {
const selectProps = {
name: 'select-column',
showSearch: true,
};
const {
onSubjectChange,
onOperatorChange,
isOperatorRelevant,
onComparatorChange,
} = useSimpleTabFilterProps(props);
const [suggestions, setSuggestions] = useState<Record<string, any>>([]);
const [currentSuggestionSearch, setCurrentSuggestionSearch] = useState('');
const [
loadingComparatorSuggestions,
setLoadingComparatorSuggestions,
] = useState(false);
useEffect(() => {
const refreshComparatorSuggestions = () => {
const { datasource } = props;
const col = props.adhocFilter.subject;
const having = props.adhocFilter.clause === CLAUSES.HAVING;
if (col && datasource && datasource.filter_select && !having) {
const controller = new AbortController();
const { signal } = controller;
if (loadingComparatorSuggestions) {
controller.abort();
}
setLoadingComparatorSuggestions(true);
SupersetClient.get({
signal,
endpoint: `/superset/filter/${datasource.type}/${datasource.id}/${col}/`,
})
.then(({ json }) => {
setSuggestions(json);
setLoadingComparatorSuggestions(false);
})
.catch(() => {
setSuggestions([]);
setLoadingComparatorSuggestions(false);
});
}
};
refreshComparatorSuggestions();
}, [props.adhocFilter.subject]);
const onInputComparatorChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
onComparatorChange(event.target.value);
};
const renderSubjectOptionLabel = (option: ColumnType) => (
<FilterDefinitionOption option={option} />
);
const clearSuggestionSearch = () => {
setCurrentSuggestionSearch('');
};
const getOptionsRemaining = () => {
const { comparator } = props.adhocFilter;
// if select is multi/value is array, we show the options not selected
const valuesFromSuggestionsLength = Array.isArray(comparator)
? comparator.filter(v => suggestions.includes(v)).length
: 0;
return suggestions?.length - valuesFromSuggestionsLength ?? 0;
};
const createSuggestionsPlaceholder = () => {
const optionsRemaining = getOptionsRemaining();
const placeholder = t('%s option(s)', optionsRemaining);
return optionsRemaining ? placeholder : '';
};
let columns = props.options;
const { subject, operator, comparator, operatorId } = props.adhocFilter;
const subjectSelectProps = {
value: subject ?? undefined,
onChange: onSubjectChange,
notFoundContent: t(
'No such column found. To filter on a metric, try the Custom SQL tab.',
),
autoFocus: !subject,
placeholder: '',
};
if (props.datasource.type === 'druid') {
subjectSelectProps.placeholder = t(
'%s column(s) and metric(s)',
columns.length,
);
} 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.placeholder =
props.adhocFilter.clause === CLAUSES.WHERE
? t('%s column(s)', columns.length)
: t('To filter on a metric, use Custom SQL tab.');
columns = props.options.filter(
option => 'column_name' in option && option.column_name,
);
}
const operatorSelectProps = {
placeholder: t(
'%s operator(s)',
OPERATORS_OPTIONS.filter(op => isOperatorRelevant(op, subject)).length,
),
value: OPERATOR_ENUM_TO_OPERATOR_TYPE[operatorId]?.display,
onChange: onOperatorChange,
autoFocus: !!subjectSelectProps.value && !operator,
name: 'select-operator',
};
const shouldFocusComparator =
!!subjectSelectProps.value && !!operatorSelectProps.value;
const comparatorSelectProps: SelectProps<any> & {
labelText: string | boolean;
} = {
allowClear: true,
showSearch: true,
mode: MULTI_OPERATORS.has(operatorId) ? 'tags' : undefined,
tokenSeparators: [',', '\n', '\t', ';'],
loading: loadingComparatorSuggestions,
value: comparator,
onChange: onComparatorChange,
notFoundContent: t('Type a value here'),
disabled: DISABLE_INPUT_OPERATORS.includes(operatorId),
placeholder: createSuggestionsPlaceholder(),
labelText:
comparator && comparator.length > 0 && createSuggestionsPlaceholder(),
autoFocus: shouldFocusComparator,
};
return (
<>
<Select
css={theme => ({
marginTop: theme.gridUnit * 4,
marginBottom: theme.gridUnit * 4,
})}
{...selectProps}
{...subjectSelectProps}
filterOption={(input, option) =>
option && option.filterBy
? option.filterBy.toLowerCase().indexOf(input.toLowerCase()) >= 0
: false
}
getPopupContainer={triggerNode => triggerNode.parentNode}
>
{columns.map(column => (
<Select.Option
value={
('id' in column && column.id) ||
('optionName' in column && column.optionName) ||
''
}
filterBy={
('saved_metric_name' in column && column.saved_metric_name) ||
('column_name' in column && column.column_name) ||
('label' in column && column.label)
}
key={
('id' in column && column.id) ||
('optionName' in column && column.optionName) ||
undefined
}
>
{renderSubjectOptionLabel(column)}
</Select.Option>
))}
</Select>
<Select
css={theme => ({ marginBottom: theme.gridUnit * 4 })}
{...selectProps}
{...operatorSelectProps}
filterOption={(input, option) =>
option && option.children
? option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
: false
}
getPopupContainer={triggerNode => triggerNode.parentNode}
>
{OPERATORS_OPTIONS.filter(op => isOperatorRelevant(op, subject)).map(
option => (
<Select.Option value={option} key={option}>
{OPERATOR_ENUM_TO_OPERATOR_TYPE[option].display}
</Select.Option>
),
)}
</Select>
{MULTI_OPERATORS.has(operatorId) || suggestions.length > 0 ? (
<SelectWithLabel
data-test="adhoc-filter-simple-value"
{...comparatorSelectProps}
getPopupContainer={triggerNode => triggerNode.parentNode}
onSearch={val => setCurrentSuggestionSearch(val)}
onSelect={clearSuggestionSearch}
onBlur={clearSuggestionSearch}
>
{suggestions.map((suggestion: string) => (
<Select.Option value={suggestion} key={suggestion}>
{suggestion}
</Select.Option>
))}
{/* enable selecting an option not included in suggestions */}
{currentSuggestionSearch &&
!suggestions.some(
(suggestion: string) => suggestion === currentSuggestionSearch,
) && (
<Select.Option value={currentSuggestionSearch}>
{currentSuggestionSearch}
</Select.Option>
)}
</SelectWithLabel>
) : (
<Input
data-test="adhoc-filter-simple-value"
name="filter-value"
ref={ref => {
if (ref && shouldFocusComparator) {
ref.blur();
}
}}
onChange={onInputComparatorChange}
value={comparator}
placeholder={t('Filter value (case sensitive)')}
disabled={DISABLE_INPUT_OPERATORS.includes(operatorId)}
/>
)}
</>
);
};
export default AdhocFilterEditPopoverSimpleTabContent;

View File

@ -17,8 +17,6 @@
* under the License.
*/
import PropTypes from 'prop-types';
import { OPERATORS } from 'src/explore/constants';
import { EXPRESSION_TYPES, CLAUSES } from './AdhocFilter';
export default PropTypes.oneOfType([
@ -26,7 +24,6 @@ export default PropTypes.oneOfType([
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),

View File

@ -51,5 +51,6 @@ export default function FilterDefinitionOption({ option }) {
/>
);
}
return null;
}
FilterDefinitionOption.propTypes = propTypes;

View File

@ -28,49 +28,78 @@ export const AGGREGATES = {
};
export const AGGREGATES_OPTIONS = Object.values(AGGREGATES);
export const OPERATORS = {
'==': '==',
'!=': '!=',
'>': '>',
'<': '<',
'>=': '>=',
'<=': '<=',
IN: 'IN',
'NOT IN': 'NOT IN',
ILIKE: 'ILIKE',
LIKE: 'LIKE',
REGEX: 'REGEX',
'IS NOT NULL': 'IS NOT NULL',
'IS NULL': 'IS NULL',
'LATEST PARTITION': 'LATEST PARTITION',
'IS TRUE': 'IS TRUE',
'IS FALSE': 'IS FALSE',
export enum Operators {
EQUALS = 'EQUALS',
NOT_EQUALS = 'NOT_EQUALS',
LESS_THAN = 'LESS_THAN',
GREATER_THAN = 'GREATER_THAN',
LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL',
GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL',
IN = 'IN',
NOT_IN = 'NOT_IN',
ILIKE = 'ILIKE',
LIKE = 'LIKE',
REGEX = 'REGEX',
IS_NOT_NULL = 'IS_NOT_NULL',
IS_NULL = 'IS_NULL',
LATEST_PARTITION = 'LATEST_PARTITION',
IS_TRUE = 'IS_TRUE',
IS_FALSE = 'IS_FALSE',
}
export interface OperatorType {
display: string;
operation: string;
}
export const OPERATOR_ENUM_TO_OPERATOR_TYPE: {
[key in Operators]: OperatorType;
} = {
[Operators.EQUALS]: { display: 'equals', operation: '==' },
[Operators.NOT_EQUALS]: { display: 'not equals', operation: '!=' },
[Operators.GREATER_THAN]: { display: '>', operation: '>' },
[Operators.LESS_THAN]: { display: '<', operation: '<' },
[Operators.GREATER_THAN_OR_EQUAL]: { display: '>=', operation: '>=' },
[Operators.LESS_THAN_OR_EQUAL]: { display: '<=', operation: '<=' },
[Operators.IN]: { display: 'IN', operation: 'IN' },
[Operators.NOT_IN]: { display: 'NOT IN', operation: 'NOT IN' },
[Operators.LIKE]: { display: 'LIKE', operation: 'LIKE' },
[Operators.ILIKE]: { display: 'LIKE (case insensitive)', operation: 'ILIKE' },
[Operators.REGEX]: { display: 'REGEX', operation: 'REGEX' },
[Operators.IS_NOT_NULL]: { display: 'IS NOT NULL', operation: 'IS NOT NULL' },
[Operators.IS_NULL]: { display: 'IS NULL', operation: 'IS NULL' },
[Operators.LATEST_PARTITION]: {
display: 'use latest_partition template',
operation: 'LATEST PARTITION',
},
[Operators.IS_TRUE]: { display: 'IS TRUE', operation: '==' },
[Operators.IS_FALSE]: { display: 'IS FALSE', operation: '==' },
};
export const OPERATORS_OPTIONS = Object.values(OPERATORS);
export const OPERATORS_OPTIONS = Object.values(Operators) as Operators[];
export const TABLE_ONLY_OPERATORS = [OPERATORS.LIKE, OPERATORS.ILIKE];
export const DRUID_ONLY_OPERATORS = [OPERATORS.REGEX];
export const TABLE_ONLY_OPERATORS = [Operators.LIKE, Operators.ILIKE];
export const DRUID_ONLY_OPERATORS = [Operators.REGEX];
export const HAVING_OPERATORS = [
OPERATORS['=='],
OPERATORS['!='],
OPERATORS['>'],
OPERATORS['<'],
OPERATORS['>='],
OPERATORS['<='],
Operators.EQUALS,
Operators.NOT_EQUALS,
Operators.GREATER_THAN,
Operators.LESS_THAN,
Operators.GREATER_THAN_OR_EQUAL,
Operators.LESS_THAN_OR_EQUAL,
];
export const MULTI_OPERATORS = new Set([OPERATORS.IN, OPERATORS['NOT IN']]);
export const MULTI_OPERATORS = new Set([Operators.IN, Operators.NOT_IN]);
// CUSTOM_OPERATORS will show operator in simple mode,
// but will generate customized sqlExpression
export const CUSTOM_OPERATORS = new Set([OPERATORS['LATEST PARTITION']]);
export const CUSTOM_OPERATORS = new Set([Operators.LATEST_PARTITION]);
// DISABLE_INPUT_OPERATORS will disable filter value input
// in adhocFilter control
export const DISABLE_INPUT_OPERATORS = [
OPERATORS['IS NOT NULL'],
OPERATORS['IS NULL'],
OPERATORS['LATEST PARTITION'],
OPERATORS['IS TRUE'],
OPERATORS['IS FALSE'],
Operators.IS_NOT_NULL,
Operators.IS_NULL,
Operators.LATEST_PARTITION,
Operators.IS_TRUE,
Operators.IS_FALSE,
];
export const sqlaAutoGeneratedMetricNameRegex = /^(sum|min|max|avg|count|count_distinct)__.*$/i;

View File

@ -29,7 +29,10 @@ import {
import { availableDomains } from 'src/utils/hostNamesConfig';
import { safeStringify } from 'src/utils/safeStringify';
import { URL_PARAMS } from 'src/constants';
import { MULTI_OPERATORS } from 'src/explore/constants';
import {
MULTI_OPERATORS,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
} from 'src/explore/constants';
import { DashboardStandaloneMode } from 'src/dashboard/util/constants';
const MAX_URL_LENGTH = 8000;
@ -319,7 +322,10 @@ export const useDebouncedEffect = (effect, delay, deps) => {
};
export const getSimpleSQLExpression = (subject, operator, comparator) => {
const isMulti = MULTI_OPERATORS.has(operator);
const isMulti =
[...MULTI_OPERATORS]
.map(op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation)
.indexOf(operator) >= 0;
let expression = subject ?? '';
if (subject && operator) {
expression += ` ${operator}`;

View File

@ -218,7 +218,7 @@ class FilterOperator(str, Enum):
class PostProcessingBoxplotWhiskerType(str, Enum):
"""
Calculate cell contibution to row/column total
Calculate cell contribution to row/column total
"""
TUKEY = "tukey"
@ -228,7 +228,7 @@ class PostProcessingBoxplotWhiskerType(str, Enum):
class PostProcessingContributionOrientation(str, Enum):
"""
Calculate cell contibution to row/column total
Calculate cell contribution to row/column total
"""
ROW = "row"