feat(native-filters): add timegrain and column filter (#13484)

* feat(native-filters): add timegrain and column filter

* add fetch values predicate

* bump deps

* lint

* fix test

* add python test for legacy merge

* fix default value and isInitialized to not check strict equality

* Address comments

* add FilterValue type

* address review comments
This commit is contained in:
Ville Brofeldt 2021-03-09 17:27:46 +02:00 committed by GitHub
parent c91c45574b
commit 375797f649
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1215 additions and 546 deletions

File diff suppressed because it is too large Load Diff

View File

@ -65,34 +65,34 @@
"@babel/runtime-corejs3": "^7.12.5",
"@data-ui/sparkline": "^0.0.84",
"@emotion/core": "^10.0.35",
"@superset-ui/chart-controls": "^0.17.14",
"@superset-ui/core": "^0.17.14",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.14",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.14",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.14",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.14",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.14",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.14",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.14",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.14",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.14",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.14",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.14",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.14",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.14",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.14",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.14",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.14",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.14",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.14",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.14",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.14",
"@superset-ui/chart-controls": "^0.17.15",
"@superset-ui/core": "^0.17.15",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.15",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.15",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.15",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.15",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.15",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.15",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.15",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.15",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.15",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.15",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.15",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.15",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.15",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.15",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.15",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.15",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.15",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.15",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.15",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.15",
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.6",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.14",
"@superset-ui/plugin-chart-echarts": "^0.17.14",
"@superset-ui/plugin-chart-table": "^0.17.14",
"@superset-ui/plugin-chart-word-cloud": "^0.17.14",
"@superset-ui/preset-chart-xy": "^0.17.14",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.15",
"@superset-ui/plugin-chart-echarts": "^0.17.15",
"@superset-ui/plugin-chart-table": "^0.17.15",
"@superset-ui/plugin-chart-word-cloud": "^0.17.15",
"@superset-ui/preset-chart-xy": "^0.17.15",
"@vx/responsive": "^0.0.195",
"abortcontroller-polyfill": "^1.1.9",
"antd": "^4.9.4",

View File

@ -107,9 +107,13 @@ describe('Filter utils', () => {
expect(getSelectExtraFormData('testCol', ['value'], true, false)).toEqual(
{
append_form_data: {
extras: {
where: '1 = 0',
},
adhoc_filters: [
{
clause: 'WHERE',
expressionType: 'SQL',
sqlExpression: '1 = 0',
},
],
},
},
);

View File

@ -22,6 +22,7 @@ import { NativeFiltersState } from 'src/dashboard/reducers/types';
import { DataMaskStateWithId } from 'src/dataMask/types';
import { Layout } from '../../types';
import { getTreeCheckedItems } from '../nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils';
import { FilterValue } from '../nativeFilters/types';
export enum IndicatorStatus {
Unset = 'UNSET',
@ -52,7 +53,7 @@ const selectIndicatorValue = (
columnKey: string,
filter: Filter,
datasource: Datasource,
): string[] => {
): FilterValue => {
const values = filter.columns[columnKey];
const arrValues = Array.isArray(values) ? values : [values];
@ -132,7 +133,7 @@ const getRejectedColumns = (chart: any): Set<string> =>
export type Indicator = {
column?: string;
name: string;
value: string[];
value: FilterValue;
status: IndicatorStatus;
path: string[];
};
@ -185,20 +186,22 @@ export const selectNativeIndicatorsForChart = (
const rejectedColumns = getRejectedColumns(chart);
const getStatus = (
value: string[],
value: FilterValue,
isAffectedByScope: boolean,
column?: string,
): IndicatorStatus => {
// a filter is only considered unset if it's value is null
const hasValue = value !== null;
if (!isAffectedByScope) {
return IndicatorStatus.Unset;
}
if (!column) {
if (!column && hasValue) {
// Filter without datasource
return IndicatorStatus.Applied;
}
if (column && rejectedColumns.has(column))
return IndicatorStatus.Incompatible;
if (column && appliedColumns.has(column) && value.length > 0) {
if (column && appliedColumns.has(column) && hasValue) {
return IndicatorStatus.Applied;
}
return IndicatorStatus.Unset;

View File

@ -60,7 +60,7 @@ const FilterValue: React.FC<FilterProps> = ({
column = {},
}: Partial<{ datasetId: number; column: { name?: string } }> = target;
const { name: groupby } = column;
const hasDataSource = !!(datasetId && groupby);
const hasDataSource = !!datasetId;
const [loading, setLoading] = useState<boolean>(hasDataSource);
useEffect(() => {
const newFormData = getFormData({
@ -137,7 +137,7 @@ const FilterValue: React.FC<FilterProps> = ({
width={220}
formData={formData}
// For charts that don't have datasource we need workaround for empty placeholder
queriesData={hasDataSource ? state : [{ data: [null] }]}
queriesData={hasDataSource ? state : [{ data: [{}] }]}
chartType={filterType}
behaviors={[Behavior.NATIVE_FILTER]}
hooks={{ setDataMask }}

View File

@ -48,7 +48,6 @@ const ControlItems: FC<ControlItemsProps> = ({
const controlPanelRegistry = getChartControlPanelRegistry();
const controlItems =
getControlItems(controlPanelRegistry.get(filterType)) ?? [];
return (
<>
{controlItems

View File

@ -78,6 +78,8 @@ export interface FiltersConfigFormProps {
parentFilters: { id: string; title: string }[];
}
const FILTERS_WITH_ONLY_DATASOURCE = ['filter_timegrain', 'filter_timecolumn'];
/**
* The configuration form for a specific filter.
* Assigns field values to `filters[filterId]` in the form.
@ -104,17 +106,21 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
// @ts-ignore
const hasDatasource = !!nativeFilterItems[formFilter?.filterType]?.value
?.datasourceCount;
const hasColumn =
hasDatasource &&
!FILTERS_WITH_ONLY_DATASOURCE.includes(formFilter?.filterType);
const hasFilledDatasource =
(formFilter?.dataset && formFilter?.column) || !hasDatasource;
!hasDatasource ||
(formFilter?.dataset?.value && (formFilter?.column || !hasColumn));
useBackendFormUpdate(form, filterId, filterToEdit, hasDatasource);
useBackendFormUpdate(form, filterId, filterToEdit, hasDatasource, hasColumn);
const initDatasetId = filterToEdit?.targets[0]?.datasetId;
const initColumn = filterToEdit?.targets[0]?.column?.name;
const newFormData = getFormData({
datasetId: formFilter?.dataset?.value,
groupby: formFilter?.column,
groupby: hasColumn ? formFilter?.column : undefined,
defaultValue: formFilter?.defaultValue,
...formFilter,
});
@ -204,22 +210,24 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
}}
/>
</StyledFormItem>
<StyledFormItem
// don't show the column select unless we have a dataset
// style={{ display: datasetId == null ? undefined : 'none' }}
name={['filters', filterId, 'column']}
initialValue={initColumn}
label={<StyledLabel>{t('Column')}</StyledLabel>}
rules={[{ required: !removed, message: t('Field is required') }]}
data-test="field-input"
>
<ColumnSelect
form={form}
filterId={filterId}
datasetId={formFilter?.dataset?.value}
onChange={forceUpdate}
/>
</StyledFormItem>
{hasColumn && (
<StyledFormItem
// don't show the column select unless we have a dataset
// style={{ display: datasetId == null ? undefined : 'none' }}
name={['filters', filterId, 'column']}
initialValue={initColumn}
label={<StyledLabel>{t('Column')}</StyledLabel>}
rules={[{ required: !removed, message: t('Field is required') }]}
data-test="field-input"
>
<ColumnSelect
form={form}
filterId={filterId}
datasetId={formFilter?.dataset?.value}
onChange={forceUpdate}
/>
</StyledFormItem>
)}
</>
)}
{hasFilledDatasource && (

View File

@ -31,6 +31,7 @@ export const useBackendFormUpdate = (
filterId: string,
filterToEdit?: Filter,
hasDatasource?: boolean,
hasColumn?: boolean,
) => {
const forceUpdate = useForceUpdate();
const formFilter = (form.getFieldValue('filters') || {})[filterId];
@ -42,13 +43,17 @@ export const useBackendFormUpdate = (
}
// No need to check data set change because it cascading update column
// So check that column exists is enough
if (!formFilter?.column) {
if (hasColumn && !formFilter?.column) {
setFilterFieldValues(form, filterId, {
defaultValueQueriesData: [],
defaultValue: resolvedDefaultValue,
});
return;
}
if (!formFilter?.dataset?.value) {
// no need to make chart data request if no dataset is defined
return;
}
const formData = getFormData({
datasetId: formFilter?.dataset?.value,
groupby: formFilter?.column,
@ -63,7 +68,8 @@ export const useBackendFormUpdate = (
if (
filterToEdit?.filterType === formFilter?.filterType &&
filterToEdit?.targets[0].datasetId === formFilter?.dataset?.value &&
formFilter?.column === filterToEdit?.targets[0].column?.name
(!hasColumn ||
formFilter?.column === filterToEdit?.targets[0].column?.name)
) {
resolvedDefaultValue = filterToEdit?.defaultValue;
}

View File

@ -19,7 +19,7 @@
import { FormInstance } from 'antd/lib/form';
import shortid from 'shortid';
import { FilterRemoval, NativeFiltersForm } from './types';
import { Filter, FilterConfiguration } from '../types';
import { Filter, FilterConfiguration, Target } from '../types';
export const REMOVAL_DELAY_SECS = 5;
@ -132,14 +132,12 @@ export const createHandleSave = (
const formInputs = values.filters[id];
// if user didn't open a filter, return the original config
if (!formInputs) return filterConfigMap[id];
let target = {};
const target: Partial<Target> = {};
if (formInputs.dataset) {
target.datasetId = formInputs.dataset.value;
}
if (formInputs.dataset && formInputs.column) {
target = {
datasetId: formInputs.dataset.value,
column: {
name: formInputs.column,
},
};
target.column = { name: formInputs.column };
}
return {
id,

View File

@ -37,10 +37,12 @@ export interface Target {
// clarityColumns?: Column[];
}
export type FilterValue = string | number | (string | number)[] | null;
export interface Filter {
cascadeParentIds: string[];
defaultValue: any;
currentValue?: any;
defaultValue: FilterValue;
currentValue?: FilterValue;
isInstant: boolean;
id: string; // randomly generated at filter creation
name: string;

View File

@ -44,12 +44,12 @@ export const getFormData = ({
cascadingFilters?: object;
groupby?: string;
}): Partial<QueryFormData> => {
let otherProps: { datasource?: string; groupby?: string[] } = {};
if (datasetId && groupby) {
otherProps = {
datasource: `${datasetId}__table`,
groupby: [groupby],
};
const otherProps: { datasource?: string; groupby?: string[] } = {};
if (datasetId) {
otherProps.datasource = `${datasetId}__table`;
}
if (groupby) {
otherProps.groupby = [groupby];
}
return {
...controlValues,

View File

@ -16,18 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { styled, t, DataMask, Behavior } from '@superset-ui/core';
import { t, DataMask, Behavior } from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import { Slider } from 'src/common/components';
import { PluginFilterRangeProps } from './types';
import { PluginFilterStylesProps } from '../types';
import { Styles } from '../common';
import { getRangeExtraFormData } from '../../utils';
const Styles = styled.div<PluginFilterStylesProps>`
height: ${({ height }) => height};
width: ${({ width }) => width};
`;
export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
const {
data,

View File

@ -43,6 +43,7 @@ export default function buildQuery(formData: QueryFormData) {
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
apply_fetch_values_predicate: true,
columns: [],
groupby: [],
metrics: [

View File

@ -26,7 +26,7 @@ export default class RangeFilterPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
name: t('Range filter'),
description: 'Range filter plugin using AntD',
description: t('Range filter plugin using AntD'),
behaviors: [Behavior.CROSS_FILTER, Behavior.NATIVE_FILTER],
thumbnail,
});

View File

@ -16,18 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { styled, Behavior, DataMask, t } from '@superset-ui/core';
import { Behavior, DataMask, t, tn, ensureIsArray } from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components';
import { PluginFilterSelectProps } from './types';
import { PluginFilterStylesProps } from '../types';
import { Styles, StyledSelect } from '../common';
import { getSelectExtraFormData } from '../../utils';
const Styles = styled.div<PluginFilterStylesProps>`
height: ${({ height }) => height};
width: ${({ width }) => width};
`;
const { Option } = Select;
export default function PluginFilterSelect(props: PluginFilterSelectProps) {
@ -50,13 +45,9 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const handleChange = (
value?: (number | string)[] | number | string | null,
) => {
let resultValue: (number | string)[];
// Works only with arrays even for single select
if (!Array.isArray(value)) {
resultValue = value ? [value] : [];
} else {
resultValue = value;
}
const resultValue: (number | string)[] = ensureIsArray<number | string>(
value,
);
setValues(resultValue);
const [col] = groupby;
@ -89,25 +80,32 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
useEffect(() => {
handleChange(currentValue ?? []);
}, [JSON.stringify(currentValue)]);
}, [
JSON.stringify(currentValue),
multiSelect,
enableEmptyFilter,
inverseSelection,
]);
useEffect(() => {
handleChange(defaultValue ?? []);
// I think after Config Modal update some filter it re-creates default value for all other filters
// so we can process it like this `JSON.stringify` or start to use `Immer`
}, [JSON.stringify(defaultValue)]);
}, [
JSON.stringify(defaultValue),
multiSelect,
enableEmptyFilter,
inverseSelection,
]);
const placeholderText =
(data || []).length === 0
? t('No data')
: t(`%d option%s`, data.length, data.length === 1 ? '' : 's');
: tn('%s option', '%s options', data.length, data.length);
return (
<Styles height={height} width={width}>
<Select
<StyledSelect
allowClear
value={values}
showSearch={showSearch}
style={{ width: '100%' }}
mode={multiSelect ? 'multiple' : undefined}
placeholder={placeholderText}
// @ts-ignore
@ -122,7 +120,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
</Option>
);
})}
</Select>
</StyledSelect>
</Styles>
);
}

View File

@ -36,6 +36,7 @@ export default function buildQuery(formData: QueryFormData) {
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
apply_fetch_values_predicate: true,
groupby: baseQueryObject.columns,
},
]);

View File

@ -26,7 +26,7 @@ export default class FilterSelectPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
name: t('Select filter'),
description: 'Select filter plugin using AntD',
description: t('Select filter plugin using AntD'),
behaviors: [Behavior.CROSS_FILTER, Behavior.NATIVE_FILTER],
thumbnail,
});

View File

@ -19,14 +19,12 @@
import { styled, DataMask, Behavior } from '@superset-ui/core';
import React, { useState, useEffect } from 'react';
import DateFilterControl from 'src/explore/components/controls/DateFilterControl';
import { PluginFilterStylesProps } from '../types';
import { PluginFilterTimeProps } from './types';
import { Styles } from '../common';
const DEFAULT_VALUE = 'Last week';
const Styles = styled.div<PluginFilterStylesProps>`
height: ${({ height }) => height}px;
width: ${({ width }) => width}px;
const TimeFilterStyles = styled(Styles)`
overflow-x: scroll;
`;
@ -69,12 +67,12 @@ export default function TimeFilterPlugin(props: PluginFilterTimeProps) {
return (
// @ts-ignore
<Styles width={width}>
<TimeFilterStyles width={width}>
<DateFilterControl
value={value}
name="time_range"
onChange={handleTimeRangeChange}
/>
</Styles>
</TimeFilterStyles>
);
}

View File

@ -25,7 +25,7 @@ export default class TimeFilterPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
name: t('Time filter'),
description: 'Custom time filter plugin',
description: t('Custom time filter plugin'),
behaviors: [Behavior.CROSS_FILTER, Behavior.NATIVE_FILTER],
thumbnail,
datasourceCount: 0,

View File

@ -0,0 +1,110 @@
/**
* 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 {
Behavior,
DataMask,
ensureIsArray,
GenericDataType,
t,
tn,
} from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components';
import { Styles, StyledSelect } from '../common';
import { PluginFilterTimeColumnProps } from './types';
const { Option } = Select;
export default function PluginFilterTimeColumn(
props: PluginFilterTimeColumnProps,
) {
const { behaviors, data, formData, height, width, setDataMask } = props;
const { defaultValue, currentValue, inputRef } = formData;
const [value, setValue] = useState<string[]>(defaultValue ?? []);
const handleChange = (value?: string[] | string | null) => {
const resultValue: string[] = ensureIsArray<string>(value);
setValue(resultValue);
const dataMask = {
extraFormData: {
override_form_data: {
granularity_sqla: resultValue.length ? resultValue[0] : null,
},
},
currentState: {
value: resultValue.length ? resultValue : null,
},
};
const dataMaskObject: DataMask = {};
if (behaviors.includes(Behavior.NATIVE_FILTER)) {
dataMaskObject.nativeFilters = dataMask;
}
if (behaviors.includes(Behavior.CROSS_FILTER)) {
dataMaskObject.crossFilters = dataMask;
}
setDataMask(dataMaskObject);
};
useEffect(() => {
handleChange(currentValue ?? null);
}, [JSON.stringify(currentValue)]);
useEffect(() => {
handleChange(defaultValue ?? null);
// I think after Config Modal update some filter it re-creates default value for all other filters
// so we can process it like this `JSON.stringify` or start to use `Immer`
}, [JSON.stringify(defaultValue)]);
const timeColumns = (data || []).filter(
row => row.dtype === GenericDataType.TEMPORAL,
);
const placeholderText =
timeColumns.length === 0
? t('No time columns')
: tn('%s option', '%s options', timeColumns.length, timeColumns.length);
return (
<Styles height={height} width={width}>
<StyledSelect
allowClear
value={value}
placeholder={placeholderText}
// @ts-ignore
onChange={handleChange}
ref={inputRef}
>
{timeColumns.map(
(row: { column_name: string; verbose_name: string | null }) => {
const { column_name: columnName, verbose_name: verboseName } = row;
return (
<Option key={columnName} value={columnName}>
{verboseName ?? columnName}
</Option>
);
},
)}
</StyledSelect>
</Styles>
);
}

View File

@ -0,0 +1,44 @@
/**
* 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 { buildQueryContext, QueryFormData } from '@superset-ui/core';
/**
* The buildQuery function is used to create an instance of QueryContext that's
* sent to the chart data endpoint. In addition to containing information of which
* datasource to use, it specifies the type (e.g. full payload, samples, query) and
* format (e.g. CSV or JSON) of the result and whether or not to force refresh the data from
* the datasource as opposed to using a cached copy of the data, if available.
*
* More importantly though, QueryContext contains a property `queries`, which is an array of
* QueryObjects specifying individual data requests to be made. A QueryObject specifies which
* columns, metrics and filters, among others, to use during the query. Usually it will be enough
* to specify just one query based on the baseQueryObject, but for some more advanced use cases
* it is possible to define post processing operations in the QueryObject, or multiple queries
* if a viz needs multiple different result sets.
*/
export default function buildQuery(formData: QueryFormData) {
return buildQueryContext(formData, baseQueryObject => [
{
result_type: 'columns',
columns: [],
metrics: [],
orderby: [],
},
]);
}

View File

@ -0,0 +1,25 @@
/**
* 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 { ControlPanelConfig } from '@superset-ui/chart-controls';
const config: ControlPanelConfig = {
controlPanelSections: [],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,42 @@
/**
* 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 { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
export default class FilterTimeColumnPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
name: t('Time column'),
description: t('Time column filter plugin'),
behaviors: [Behavior.CROSS_FILTER, Behavior.NATIVE_FILTER],
thumbnail,
});
super({
buildQuery,
controlPanel,
loadChart: () => import('./TimeColumnFilterPlugin'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,36 @@
/**
* 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 { ChartProps } from '@superset-ui/core';
import { DEFAULT_FORM_DATA } from './types';
export default function transformProps(chartProps: ChartProps) {
const { behaviors, formData, height, hooks, queriesData, width } = chartProps;
const { setDataMask = () => {} } = hooks;
const { data } = queriesData[0];
return {
behaviors,
width,
height,
data,
formData: { ...DEFAULT_FORM_DATA, ...formData },
setDataMask,
};
}

View File

@ -0,0 +1,48 @@
/**
* 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 {
QueryFormData,
DataRecord,
SetDataMaskHook,
Behavior,
} from '@superset-ui/core';
import { RefObject } from 'react';
import { PluginFilterStylesProps } from '../types';
interface PluginFilterTimeColumnCustomizeProps {
defaultValue?: string[] | null;
currentValue?: string[] | null;
inputRef?: RefObject<HTMLInputElement>;
}
export type PluginFilterTimeColumnQueryFormData = QueryFormData &
PluginFilterStylesProps &
PluginFilterTimeColumnCustomizeProps;
export type PluginFilterTimeColumnProps = PluginFilterStylesProps & {
behaviors: Behavior[];
data: DataRecord[];
setDataMask: SetDataMaskHook;
formData: PluginFilterTimeColumnQueryFormData;
};
export const DEFAULT_FORM_DATA: PluginFilterTimeColumnCustomizeProps = {
defaultValue: null,
currentValue: null,
};

View File

@ -0,0 +1,93 @@
/**
* 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 { ensureIsArray, QueryObjectExtras, t, tn } from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components';
import { Styles, StyledSelect } from '../common';
import { PluginFilterTimeGrainProps } from './types';
const { Option } = Select;
export default function PluginFilterTimegrain(
props: PluginFilterTimeGrainProps,
) {
const { data, formData, height, width, setDataMask } = props;
const { defaultValue, currentValue, inputRef } = formData;
const [value, setValue] = useState<string[]>(defaultValue ?? []);
const handleChange = (values: string[] | string | undefined | null) => {
const resultValue: string[] = ensureIsArray<string>(values);
const [timeGrain] = resultValue;
const extras: QueryObjectExtras = {};
if (timeGrain) {
extras.time_grain_sqla = timeGrain;
}
setValue(resultValue);
setDataMask({
nativeFilters: {
extraFormData: {
override_form_data: {
extras,
},
},
currentState: {
value: resultValue.length ? resultValue : null,
},
},
});
};
useEffect(() => {
handleChange(currentValue ?? []);
}, [JSON.stringify(currentValue)]);
useEffect(() => {
handleChange(defaultValue ?? []);
// I think after Config Modal update some filter it re-creates default value for all other filters
// so we can process it like this `JSON.stringify` or start to use `Immer`
}, [JSON.stringify(defaultValue)]);
const placeholderText =
(data || []).length === 0
? t('No data')
: tn('%s option', '%s options', data.length, data.length);
return (
<Styles height={height} width={width}>
<StyledSelect
allowClear
value={value}
placeholder={placeholderText}
// @ts-ignore
onChange={handleChange}
ref={inputRef}
>
{(data || []).map((row: { name: string; duration: string }) => {
const { name, duration } = row;
return (
<Option key={duration} value={duration}>
{name}
</Option>
);
})}
</StyledSelect>
</Styles>
);
}

View File

@ -0,0 +1,44 @@
/**
* 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 { buildQueryContext, QueryFormData } from '@superset-ui/core';
/**
* The buildQuery function is used to create an instance of QueryContext that's
* sent to the chart data endpoint. In addition to containing information of which
* datasource to use, it specifies the type (e.g. full payload, samples, query) and
* format (e.g. CSV or JSON) of the result and whether or not to force refresh the data from
* the datasource as opposed to using a cached copy of the data, if available.
*
* More importantly though, QueryContext contains a property `queries`, which is an array of
* QueryObjects specifying individual data requests to be made. A QueryObject specifies which
* columns, metrics and filters, among others, to use during the query. Usually it will be enough
* to specify just one query based on the baseQueryObject, but for some more advanced use cases
* it is possible to define post processing operations in the QueryObject, or multiple queries
* if a viz needs multiple different result sets.
*/
export default function buildQuery(formData: QueryFormData) {
return buildQueryContext(formData, () => [
{
result_type: 'timegrains',
columns: [],
metrics: [],
orderby: [],
},
]);
}

View File

@ -0,0 +1,25 @@
/**
* 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 { ControlPanelConfig } from '@superset-ui/chart-controls';
const config: ControlPanelConfig = {
controlPanelSections: [],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,42 @@
/**
* 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 { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
export default class FilterTimeGrainPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
name: t('Time grain'),
description: t('Time grain filter plugin'),
behaviors: [Behavior.CROSS_FILTER, Behavior.NATIVE_FILTER],
thumbnail,
});
super({
buildQuery,
controlPanel,
loadChart: () => import('./TimeGrainFilterPlugin'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,35 @@
/**
* 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 { ChartProps } from '@superset-ui/core';
import { DEFAULT_FORM_DATA } from './types';
export default function transformProps(chartProps: ChartProps) {
const { formData, height, hooks, queriesData, width } = chartProps;
const { setDataMask = () => {} } = hooks;
const { data } = queriesData[0];
return {
width,
height,
data,
formData: { ...DEFAULT_FORM_DATA, ...formData },
setDataMask,
};
}

View File

@ -0,0 +1,42 @@
/**
* 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 { QueryFormData, DataRecord, SetDataMaskHook } from '@superset-ui/core';
import { RefObject } from 'react';
import { PluginFilterStylesProps } from '../types';
interface PluginFilterTimeGrainCustomizeProps {
defaultValue?: string[] | null;
currentValue?: string[] | null;
inputRef?: RefObject<HTMLInputElement>;
}
export type PluginFilterTimeGrainQueryFormData = QueryFormData &
PluginFilterStylesProps &
PluginFilterTimeGrainCustomizeProps;
export type PluginFilterTimeGrainProps = PluginFilterStylesProps & {
data: DataRecord[];
setDataMask: SetDataMaskHook;
formData: PluginFilterTimeGrainQueryFormData;
};
export const DEFAULT_FORM_DATA: PluginFilterTimeGrainCustomizeProps = {
defaultValue: null,
currentValue: null,
};

View File

@ -0,0 +1,30 @@
/**
* 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 { styled } from '@superset-ui/core';
import { Select } from 'src/common/components';
import { PluginFilterStylesProps } from './types';
export const Styles = styled.div<PluginFilterStylesProps>`
height: ${({ height }) => height};
width: ${({ width }) => width};
`;
export const StyledSelect = styled(Select)`
width: 100%;
`;

View File

@ -19,3 +19,5 @@
export { default as SelectFilterPlugin } from './Select';
export { default as RangeFilterPlugin } from './Range';
export { default as TimeFilterPlugin } from './Time';
export { default as TimeColumnFilterPlugin } from './TimeColumn';
export { default as TimeGrainFilterPlugin } from './TimeGrain';

View File

@ -26,9 +26,13 @@ export const getSelectExtraFormData = (
) => ({
append_form_data: emptyFilter
? {
extras: {
where: '1 = 0',
},
adhoc_filters: [
{
expressionType: 'SQL',
clause: 'WHERE',
sqlExpression: '1 = 0',
},
],
}
: {
filters:

View File

@ -63,6 +63,8 @@ import {
SelectFilterPlugin,
RangeFilterPlugin,
TimeFilterPlugin,
TimeColumnFilterPlugin,
TimeGrainFilterPlugin,
} from 'src/filters/components/';
import FilterBoxChartPlugin from '../FilterBox/FilterBoxChartPlugin';
import TimeTableChartPlugin from '../TimeTable/TimeTableChartPlugin';
@ -115,6 +117,8 @@ export default class MainPreset extends Preset {
new SelectFilterPlugin().configure({ key: 'filter_select' }),
new RangeFilterPlugin().configure({ key: 'filter_range' }),
new TimeFilterPlugin().configure({ key: 'filter_time' }),
new TimeColumnFilterPlugin().configure({ key: 'filter_timecolumn' }),
new TimeGrainFilterPlugin().configure({ key: 'filter_timegrain' }),
],
});
}

View File

@ -908,6 +908,11 @@ class ChartDataQueryObjectSchema(Schema):
allow_none=True,
example={"__time_range": "1 year ago : now"},
)
apply_fetch_values_predicate = fields.Boolean(
description="Add fetch values predicate (where clause) to query "
"if defined in datasource",
allow_none=True,
)
filters = fields.List(fields.Nested(ChartDataFilterSchema), allow_none=True)
granularity = fields.String(
description="Name of temporal column used for time filtering. For legacy Druid "

View File

@ -73,6 +73,7 @@ class QueryObject:
annotation_layers: List[Dict[str, Any]]
applied_time_extras: Dict[str, str]
apply_fetch_values_predicate: bool
granularity: Optional[str]
from_dttm: Optional[datetime]
to_dttm: Optional[datetime]
@ -100,6 +101,7 @@ class QueryObject:
result_type: Optional[ChartDataResultType] = None,
annotation_layers: Optional[List[Dict[str, Any]]] = None,
applied_time_extras: Optional[Dict[str, str]] = None,
apply_fetch_values_predicate: bool = False,
granularity: Optional[str] = None,
metrics: Optional[List[Union[Dict[str, Any], str]]] = None,
groupby: Optional[List[str]] = None,
@ -127,6 +129,7 @@ class QueryObject:
)
self.result_type = result_type
annotation_layers = annotation_layers or []
self.apply_fetch_values_predicate = apply_fetch_values_predicate or False
metrics = metrics or []
columns = columns or []
groupby = groupby or []
@ -262,6 +265,7 @@ class QueryObject:
def to_dict(self) -> Dict[str, Any]:
query_object_dict = {
"apply_fetch_values_predicate": self.apply_fetch_values_predicate,
"granularity": self.granularity,
"groupby": self.groupby,
"from_dttm": self.from_dttm,
@ -291,18 +295,24 @@ class QueryObject:
"""
cache_dict = self.to_dict()
cache_dict.update(extra)
# TODO: the below KVs can all be cleaned up and moved to `to_dict()` at some
# predetermined point in time when orgs are aware that the previously
# chached results will be invalidated.
if not self.apply_fetch_values_predicate:
del cache_dict["apply_fetch_values_predicate"]
if self.datasource:
cache_dict["datasource"] = self.datasource.uid
if self.result_type:
cache_dict["result_type"] = self.result_type
for k in ["from_dttm", "to_dttm"]:
del cache_dict[k]
if self.time_range:
cache_dict["time_range"] = self.time_range
if self.post_processing:
cache_dict["post_processing"] = self.post_processing
for k in ["from_dttm", "to_dttm"]:
del cache_dict[k]
annotation_fields = [
"annotationType",
"descriptionColumns",

View File

@ -1139,11 +1139,17 @@ class DruidDatasource(Model, BaseDatasource):
client: Optional["PyDruid"] = None,
order_desc: bool = True,
is_rowcount: bool = False,
apply_fetch_values_predicate: bool = False,
) -> str:
"""Runs a query against Druid and returns a dataframe."""
# is_rowcount is only supported on SQL connector
# is_rowcount and apply_fetch_values_predicate is only
# supported on SQL connector
if is_rowcount:
raise SupersetException("is_rowcount is not supported on Druid connector")
if apply_fetch_values_predicate:
raise SupersetException(
"apply_fetch_values_predicate is not supported on Druid connector"
)
# TODO refactor into using a TBD Query object
client = client or self.cluster.get_pydruid_client()

View File

@ -48,7 +48,7 @@ from sqlalchemy import (
from sqlalchemy.orm import backref, Query, relationship, RelationshipProperty, Session
from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.sql import column, ColumnElement, literal_column, table, text
from sqlalchemy.sql.expression import Label, Select, TextAsFrom
from sqlalchemy.sql.expression import Label, Select, TextAsFrom, TextClause
from sqlalchemy.types import TypeEngine
from superset import app, db, is_feature_enabled, security_manager
@ -720,6 +720,18 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
except (TypeError, json.JSONDecodeError):
return {}
def get_fetch_values_predicate(self) -> TextClause:
tp = self.get_template_processor()
try:
return text(tp.process_template(self.fetch_values_predicate))
except TemplateError as ex:
raise QueryObjectValidationError(
_(
"Error in jinja expression in fetch values predicate: %(msg)s",
msg=ex.message,
)
)
def values_for_column(self, column_name: str, limit: int = 10000) -> List[Any]:
"""Runs query against sqla to retrieve some
sample values for the given column.
@ -737,16 +749,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
qry = qry.limit(limit)
if self.fetch_values_predicate:
tp = self.get_template_processor()
try:
qry = qry.where(text(tp.process_template(self.fetch_values_predicate)))
except TemplateError as ex:
raise QueryObjectValidationError(
_(
"Error in jinja expression in fetch values predicate: %(msg)s",
msg=ex.message,
)
)
qry = qry.where(self.get_fetch_values_predicate())
engine = self.database.get_sqla_engine()
sql = "{}".format(qry.compile(engine, compile_kwargs={"literal_binds": True}))
@ -899,6 +902,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
extras: Optional[Dict[str, Any]] = None,
order_desc: bool = True,
is_rowcount: bool = False,
apply_fetch_values_predicate: bool = False,
) -> SqlaQuery:
"""Querying any sqla table from this common interface"""
template_kwargs = {
@ -1133,6 +1137,8 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
)
)
having_clause_and += [sa.text("({})".format(having))]
if apply_fetch_values_predicate and self.fetch_values_predicate:
qry = qry.where(self.get_fetch_values_predicate())
if granularity:
qry = qry.where(and_(*(time_filters + where_clause_and)))
else:

View File

@ -40,7 +40,7 @@ from typing import (
import pandas as pd
import sqlparse
from flask import g
from flask_babel import lazy_gettext as _
from flask_babel import gettext as __, lazy_gettext as _
from sqlalchemy import column, DateTime, select
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.interfaces import Compiled, Dialect
@ -77,23 +77,23 @@ QueryStatus = utils.QueryStatus
config = app.config
builtin_time_grains: Dict[Optional[str], str] = {
None: "Time Column",
"PT1S": "second",
"PT1M": "minute",
"PT5M": "5 minute",
"PT10M": "10 minute",
"PT15M": "15 minute",
"PT0.5H": "half hour",
"PT1H": "hour",
"P1D": "day",
"P1W": "week",
"P1M": "month",
"P0.25Y": "quarter",
"P1Y": "year",
"1969-12-28T00:00:00Z/P1W": "week_start_sunday",
"1969-12-29T00:00:00Z/P1W": "week_start_monday",
"P1W/1970-01-03T00:00:00Z": "week_ending_saturday",
"P1W/1970-01-04T00:00:00Z": "week_ending_sunday",
None: __("Original value"),
"PT1S": __("Second"),
"PT1M": __("Minute"),
"PT5M": __("5 minute"),
"PT10M": __("10 minute"),
"PT15M": __("15 minute"),
"PT0.5H": __("Half hour"),
"PT1H": __("Hour"),
"P1D": __("Day"),
"P1W": __("Week"),
"P1M": __("Month"),
"P0.25Y": __("Quarter"),
"P1Y": __("Year"),
"1969-12-28T00:00:00Z/P1W": __("Week starting sunday"),
"1969-12-29T00:00:00Z/P1W": __("Week starting monday"),
"P1W/1970-01-03T00:00:00Z": __("Week ending saturday"),
"P1W/1970-01-04T00:00:00Z": __("Week_ending sunday"),
}

View File

@ -1042,12 +1042,16 @@ def merge_extra_form_data(form_data: Dict[str, Any]) -> None:
and add applied time extras to the payload.
"""
time_extras = {
"granularity": "__granularity",
"granularity_sqla": "__granularity",
"time_range": "__time_range",
"granularity_sqla": "__time_col",
}
allowed_extra_overrides: Dict[str, Optional[str]] = {
"time_grain_sqla": "__time_grain",
"druid_time_origin": "__time_origin",
"granularity": "__granularity",
"time_range_endpoints": None,
}
applied_time_extras = form_data.get("applied_time_extras", {})
form_data["applied_time_extras"] = applied_time_extras
extra_form_data = form_data.pop("extra_form_data", {})
@ -1060,12 +1064,20 @@ def merge_extra_form_data(form_data: Dict[str, Any]) -> None:
time_extra = time_extras.get(key)
if time_extra:
applied_time_extras[time_extra] = value
extras = form_data.get("extras", {})
for key, value in allowed_extra_overrides.items():
extra = extras.get(key)
if value and extra:
applied_time_extras[value] = extra
form_data.update(extras)
adhoc_filters = form_data.get("adhoc_filters", [])
form_data["adhoc_filters"] = adhoc_filters
append_adhoc_filters = append_form_data.get("adhoc_filters", [])
adhoc_filters.extend({"isExtra": True, **fltr} for fltr in append_adhoc_filters)
if append_filters:
adhoc_filters.extend(
[to_adhoc({"isExtra": True, **fltr}) for fltr in append_filters if fltr]
to_adhoc({"isExtra": True, **fltr}) for fltr in append_filters if fltr
)

View File

@ -107,7 +107,7 @@ class TestCore(SupersetTestCase):
self.login(username="admin")
slc = self.get_slice("Girls", db.session)
resp = self.get_resp("/superset/slice/{}/".format(slc.id))
assert "Time Column" in resp
assert "Original value" in resp
assert "List Roles" in resp
# Testing overrides

View File

@ -17,7 +17,7 @@
"""Utils to provide dashboards for tests"""
import json
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from pandas import DataFrame
@ -34,6 +34,7 @@ def create_table_for_dashboard(
database: Database,
dtype: Dict[str, Any],
table_description: str = "",
fetch_values_predicate: Optional[str] = None,
) -> SqlaTable:
df.to_sql(
table_name,
@ -51,6 +52,8 @@ def create_table_for_dashboard(
)
if not table:
table = table_source(table_name=table_name)
if fetch_values_predicate:
table.fetch_values_predicate = fetch_values_predicate
table.database = database
table.description = table_description
db.session.merge(table)

View File

@ -225,7 +225,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"default_endpoint": None,
"description": "",
"extra": None,
"fetch_values_predicate": None,
"fetch_values_predicate": "123 = 123",
"filter_select_enabled": True,
"main_dttm_col": "ds",
"metrics": [

View File

@ -18,7 +18,7 @@ import json
import string
from datetime import date, datetime
from random import choice, getrandbits, randint, random, uniform
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import pandas as pd
import pytest
@ -31,7 +31,7 @@ from superset.models.core import Database
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils.core import get_example_database
from tests.dashboard_utils import create_dashboard, create_table_for_dashboard
from tests.dashboard_utils import create_table_for_dashboard
from tests.test_app import app
@ -63,7 +63,13 @@ def _load_data():
"state": String(10),
"name": String(255),
}
table = _create_table(df, table_name, database, dtype)
table = _create_table(
df=df,
table_name=table_name,
database=database,
dtype=dtype,
fetch_values_predicate="123 = 123",
)
from superset.examples.birth_names import create_slices, create_dashboard
@ -75,9 +81,19 @@ def _load_data():
def _create_table(
df: DataFrame, table_name: str, database: "Database", dtype: Dict[str, Any]
df: DataFrame,
table_name: str,
database: "Database",
dtype: Dict[str, Any],
fetch_values_predicate: Optional[str] = None,
):
table = create_table_for_dashboard(df, table_name, database, dtype)
table = create_table_for_dashboard(
df=df,
table_name=table_name,
database=database,
dtype=dtype,
fetch_values_predicate=fetch_values_predicate,
)
from superset.examples.birth_names import _add_table_metrics, _set_table_metadata
_set_table_metadata(table, database)

View File

@ -302,11 +302,44 @@ class TestQueryContext(SupersetTestCase):
payload["result_type"] = ChartDataResultType.QUERY.value
query_context = ChartDataQueryContextSchema().load(payload)
responses = query_context.get_payload()
self.assertEqual(len(responses), 1)
assert len(responses) == 1
response = responses["queries"][0]
self.assertEqual(len(response), 2)
self.assertEqual(response["language"], "sql")
self.assertIn("SELECT", response["query"])
assert len(response) == 2
assert response["language"] == "sql"
assert "SELECT" in response["query"]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_fetch_values_predicate_in_query(self):
"""
Ensure that fetch values predicate is added to query
"""
self.login(username="admin")
payload = get_query_context("birth_names")
payload["result_type"] = ChartDataResultType.QUERY.value
payload["queries"][0]["apply_fetch_values_predicate"] = True
query_context = ChartDataQueryContextSchema().load(payload)
responses = query_context.get_payload()
assert len(responses) == 1
response = responses["queries"][0]
assert len(response) == 2
assert response["language"] == "sql"
assert "123 = 123" in response["query"]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_fetch_values_predicate_not_in_query(self):
"""
Ensure that fetch values predicate is not added to query
"""
self.login(username="admin")
payload = get_query_context("birth_names")
payload["result_type"] = ChartDataResultType.QUERY.value
query_context = ChartDataQueryContextSchema().load(payload)
responses = query_context.get_payload()
assert len(responses) == 1
response = responses["queries"][0]
assert len(response) == 2
assert response["language"] == "sql"
assert "123 = 123" not in response["query"]
def test_query_object_unknown_fields(self):
"""

View File

@ -924,15 +924,38 @@ class TestUtils(SupersetTestCase):
"time_range": "Last 10 days",
"extra_form_data": {
"append_form_data": {
"filters": [{"col": "foo", "op": "IN", "val": "bar"}]
"filters": [{"col": "foo", "op": "IN", "val": ["bar"]}],
"adhoc_filters": [
{
"expressionType": "SQL",
"clause": "WHERE",
"sqlExpression": "1 = 0",
}
],
},
"override_form_data": {"time_range": "Last 100 years",},
},
}
merge_extra_form_data(form_data)
assert form_data["applied_time_extras"] == {"__time_range": "Last 100 years"}
adhoc_filters = form_data["adhoc_filters"]
assert adhoc_filters[0] == {
"clause": "WHERE",
"expressionType": "SQL",
"isExtra": True,
"sqlExpression": "1 = 0",
}
converted_filter = adhoc_filters[1]
del converted_filter["filterOptionName"]
assert converted_filter == {
"clause": "WHERE",
"comparator": ["bar"],
"expressionType": "SIMPLE",
"isExtra": True,
"operator": "IN",
"subject": "foo",
}
assert form_data["time_range"] == "Last 100 years"
assert len(form_data["adhoc_filters"]) == 1
def test_ssl_certificate_parse(self):
parsed_certificate = parse_ssl_cert(ssl_certificate)