feat(cross-filters): add support for temporal filters (#16139)

* feat(cross-filters): add support for temporal filters

* fix test

* make filter optional

* remove mocks

* fix more tests

* remove unnecessary optionality

* fix even more tests

* bump superset-ui

* add isExtra to schema

* address comments

* fix presto test
This commit is contained in:
Ville Brofeldt 2021-08-10 19:18:46 +03:00 committed by GitHub
parent 5488a8a948
commit 63ace7b288
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 543 additions and 439 deletions

File diff suppressed because it is too large Load Diff

View File

@ -67,35 +67,35 @@
"@emotion/babel-preset-css-prop": "^11.2.0",
"@emotion/cache": "^11.1.3",
"@emotion/react": "^11.1.5",
"@superset-ui/chart-controls": "^0.17.79",
"@superset-ui/core": "^0.17.75",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.79",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.79",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.79",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.79",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.79",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.79",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.79",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.79",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.79",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.79",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.79",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.79",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.79",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.79",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.79",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.79",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.79",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.79",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.79",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.79",
"@superset-ui/chart-controls": "^0.17.80",
"@superset-ui/core": "^0.17.80",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.80",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.80",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.80",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.80",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.80",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.80",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.80",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.80",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.80",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.80",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.80",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.80",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.80",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.80",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.80",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.80",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.80",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.80",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.80",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.80",
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.9",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.79",
"@superset-ui/plugin-chart-echarts": "^0.17.79",
"@superset-ui/plugin-chart-pivot-table": "^0.17.79",
"@superset-ui/plugin-chart-table": "^0.17.79",
"@superset-ui/plugin-chart-word-cloud": "^0.17.79",
"@superset-ui/preset-chart-xy": "^0.17.79",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.80",
"@superset-ui/plugin-chart-echarts": "^0.17.80",
"@superset-ui/plugin-chart-pivot-table": "^0.17.80",
"@superset-ui/plugin-chart-table": "^0.17.80",
"@superset-ui/plugin-chart-word-cloud": "^0.17.80",
"@superset-ui/preset-chart-xy": "^0.17.80",
"@vx/responsive": "^0.0.195",
"abortcontroller-polyfill": "^1.1.9",
"antd": "^4.9.4",

View File

@ -293,6 +293,13 @@ class ChartDataAdhocMetricSchema(Schema):
"will be generated.",
example="metric_aec60732-fac0-4b17-b736-93f1a5c93e30",
)
timeGrain = fields.String(
description="Optional time grain for temporal filters", example="PT1M",
)
isExtra = fields.Boolean(
description="Indicates if the filter has been added by a filter component as "
"opposed to being a part of the original query."
)
class ChartDataAggregateConfigField(fields.Dict):
@ -771,6 +778,13 @@ class ChartDataFilterSchema(Schema):
"integer, decimal or list, depending on the operator.",
example=["China", "France", "Japan"],
)
grain = fields.String(
description="Optional time grain for temporal filters", example="PT1M",
)
isExtra = fields.Boolean(
description="Indicates if the filter has been added by a filter component as "
"opposed to being a part of the original query."
)
class ChartDataExtrasSchema(Schema):

View File

@ -36,6 +36,7 @@ from superset.utils.core import (
get_metric_names,
is_adhoc_metric,
json_int_dttm_ser,
QueryObjectFilterClause,
)
from superset.utils.date_parser import get_since_until, parse_human_timedelta
from superset.utils.hashing import md5_sha_from_dict
@ -85,7 +86,7 @@ class QueryObject:
metrics: Optional[List[Metric]]
row_limit: int
row_offset: int
filter: List[Dict[str, Any]]
filter: List[QueryObjectFilterClause]
timeseries_limit: int
timeseries_limit_metric: Optional[Metric]
order_desc: bool
@ -108,7 +109,7 @@ class QueryObject:
granularity: Optional[str] = None,
metrics: Optional[List[Metric]] = None,
groupby: Optional[List[str]] = None,
filters: Optional[List[Dict[str, Any]]] = None,
filters: Optional[List[QueryObjectFilterClause]] = None,
time_range: Optional[str] = None,
time_shift: Optional[str] = None,
is_timeseries: Optional[bool] = None,

View File

@ -86,7 +86,11 @@ from superset.models.helpers import AuditMixinNullable, QueryResult
from superset.sql_parse import ParsedQuery
from superset.typing import AdhocMetric, Metric, OrderBy, QueryObjectDict
from superset.utils import core as utils
from superset.utils.core import GenericDataType, remove_duplicates
from superset.utils.core import (
GenericDataType,
QueryObjectFilterClause,
remove_duplicates,
)
config = app.config
metadata = Model.metadata # pylint: disable=no-member
@ -303,13 +307,15 @@ class TableColumn(Model, BaseColumn):
pdf = self.python_date_format
is_epoch = pdf in ("epoch_s", "epoch_ms")
column_spec = self.db_engine_spec.get_column_spec(self.type)
type_ = column_spec.sqla_type if column_spec else DateTime
if not self.expression and not time_grain and not is_epoch:
sqla_col = column(self.column_name, type_=DateTime)
sqla_col = column(self.column_name, type_=type_)
return self.table.make_sqla_column_compatible(sqla_col, label)
if self.expression:
col = literal_column(self.expression)
col = literal_column(self.expression, type_=type_)
else:
col = column(self.column_name)
col = column(self.column_name, type_=type_)
time_expr = self.db_engine_spec.get_timestamp_expr(
col, pdf, time_grain, self.type
)
@ -935,7 +941,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
columns: Optional[List[str]] = None,
groupby: Optional[List[str]] = None,
filter: Optional[ # pylint: disable=redefined-builtin
List[Dict[str, Any]]
List[QueryObjectFilterClause]
] = None,
is_timeseries: bool = True,
timeseries_limit: int = 15,
@ -1056,6 +1062,8 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
# filter out the pseudo column __timestamp from columns
columns = columns or []
columns = [col for col in columns if col != utils.DTTM_ALIAS]
time_grain = extras.get("time_grain_sqla")
dttm_col = columns_by_name.get(granularity) if granularity else None
if need_groupby:
# dedup columns while preserving order
@ -1063,7 +1071,6 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
for selected in columns:
# if groupby field/expr equals granularity field/expr
if selected == granularity:
time_grain = extras.get("time_grain_sqla")
sqla_col = columns_by_name[selected]
outer = sqla_col.get_timestamp_expression(time_grain, selected)
# if groupby field equals a selected column
@ -1087,15 +1094,13 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
groupby_exprs_with_timestamp = OrderedDict(groupby_exprs_sans_timestamp.items())
if granularity:
if granularity not in columns_by_name:
if granularity not in columns_by_name or not dttm_col:
raise QueryObjectValidationError(
_(
'Time column "%(col)s" does not exist in dataset',
col=granularity,
)
)
dttm_col = columns_by_name[granularity]
time_grain = extras.get("time_grain_sqla")
time_filters = []
if is_timeseries:
@ -1150,7 +1155,12 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
col = flt["col"]
val = flt.get("val")
op = flt["op"].upper()
col_obj = columns_by_name.get(col)
col_obj = (
dttm_col
if col == utils.DTTM_ALIAS and is_timeseries and dttm_col
else columns_by_name.get(col)
)
filter_grain = flt.get("grain")
if is_feature_enabled("ENABLE_TEMPLATE_REMOVE_FILTERS"):
if col in removed_filters:
@ -1158,6 +1168,10 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
continue
if col_obj:
if filter_grain:
sqla_col = col_obj.get_timestamp_expression(filter_grain)
else:
sqla_col = col_obj.get_sqla_col()
col_spec = db_engine_spec.get_column_spec(col_obj.type)
is_list_target = op in (
utils.FilterOperator.IN.value,
@ -1180,24 +1194,24 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
)
if None in eq:
eq = [x for x in eq if x is not None]
is_null_cond = col_obj.get_sqla_col().is_(None)
is_null_cond = sqla_col.is_(None)
if eq:
cond = or_(is_null_cond, col_obj.get_sqla_col().in_(eq))
cond = or_(is_null_cond, sqla_col.in_(eq))
else:
cond = is_null_cond
else:
cond = col_obj.get_sqla_col().in_(eq)
cond = sqla_col.in_(eq)
if op == utils.FilterOperator.NOT_IN.value:
cond = ~cond
where_clause_and.append(cond)
elif op == utils.FilterOperator.IS_NULL.value:
where_clause_and.append(col_obj.get_sqla_col().is_(None))
where_clause_and.append(sqla_col.is_(None))
elif op == utils.FilterOperator.IS_NOT_NULL.value:
where_clause_and.append(col_obj.get_sqla_col().isnot(None))
where_clause_and.append(sqla_col.isnot(None))
elif op == utils.FilterOperator.IS_TRUE.value:
where_clause_and.append(col_obj.get_sqla_col().is_(True))
where_clause_and.append(sqla_col.is_(True))
elif op == utils.FilterOperator.IS_FALSE.value:
where_clause_and.append(col_obj.get_sqla_col().is_(False))
where_clause_and.append(sqla_col.is_(False))
else:
if eq is None:
raise QueryObjectValidationError(
@ -1207,21 +1221,21 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
)
)
if op == utils.FilterOperator.EQUALS.value:
where_clause_and.append(col_obj.get_sqla_col() == eq)
where_clause_and.append(sqla_col == eq)
elif op == utils.FilterOperator.NOT_EQUALS.value:
where_clause_and.append(col_obj.get_sqla_col() != eq)
where_clause_and.append(sqla_col != eq)
elif op == utils.FilterOperator.GREATER_THAN.value:
where_clause_and.append(col_obj.get_sqla_col() > eq)
where_clause_and.append(sqla_col > eq)
elif op == utils.FilterOperator.LESS_THAN.value:
where_clause_and.append(col_obj.get_sqla_col() < eq)
where_clause_and.append(sqla_col < eq)
elif op == utils.FilterOperator.GREATER_THAN_OR_EQUALS.value:
where_clause_and.append(col_obj.get_sqla_col() >= eq)
where_clause_and.append(sqla_col >= eq)
elif op == utils.FilterOperator.LESS_THAN_OR_EQUALS.value:
where_clause_and.append(col_obj.get_sqla_col() <= eq)
where_clause_and.append(sqla_col <= eq)
elif op == utils.FilterOperator.LIKE.value:
where_clause_and.append(col_obj.get_sqla_col().like(eq))
where_clause_and.append(sqla_col.like(eq))
elif op == utils.FilterOperator.ILIKE.value:
where_clause_and.append(col_obj.get_sqla_col().ilike(eq))
where_clause_and.append(sqla_col.ilike(eq))
else:
raise QueryObjectValidationError(
_("Invalid filter operation type: %(op)s", op=op)
@ -1281,6 +1295,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
and timeseries_limit
and not time_groupby_inline
and groupby_exprs_sans_timestamp
and dttm_col
):
if db_engine_spec.allows_joins:
# some sql dialects require for order by expressions

View File

@ -44,7 +44,7 @@ from flask import current_app, g
from flask_babel import gettext as __, lazy_gettext as _
from marshmallow import fields, Schema
from marshmallow.validate import Range
from sqlalchemy import column, DateTime, select, types
from sqlalchemy import column, select, types
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.interfaces import Compiled, Dialect
from sqlalchemy.engine.reflection import Inspector
@ -381,7 +381,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
elif pdf == "epoch_ms":
time_expr = time_expr.replace("{col}", cls.epoch_ms_to_dttm())
return TimestampExpression(time_expr, col, type_=DateTime)
return TimestampExpression(time_expr, col, type_=col.type)
@classmethod
def get_time_grains(cls) -> Tuple[TimeGrain, ...]:

View File

@ -42,7 +42,7 @@ DbapiDescriptionRow = Tuple[
]
DbapiDescription = Union[List[DbapiDescriptionRow], Tuple[DbapiDescriptionRow, ...]]
DbapiResult = Sequence[Union[List[Any], Tuple[Any, ...]]]
FilterValue = Union[datetime, float, int, str]
FilterValue = Union[bool, datetime, float, int, str]
FilterValues = Union[FilterValue, List[FilterValue], Tuple[FilterValue]]
FormData = Dict[str, Any]
Granularity = Union[str, Dict[str, Union[str, float]]]

View File

@ -96,7 +96,7 @@ from superset.exceptions import (
SupersetException,
SupersetTimeoutException,
)
from superset.typing import AdhocMetric, FlaskResponse, FormData, Metric
from superset.typing import AdhocMetric, FilterValues, FlaskResponse, FormData, Metric
from superset.utils.dates import datetime_to_epoch, EPOCH
from superset.utils.hashing import md5_sha_from_dict, md5_sha_from_str
@ -189,6 +189,25 @@ class DatasourceDict(TypedDict):
id: int
class AdhocFilterClause(TypedDict, total=False):
clause: str
expressionType: str
filterOptionName: Optional[str]
comparator: Optional[FilterValues]
operator: str
subject: str
isExtra: Optional[bool]
sqlExpression: Optional[str]
class QueryObjectFilterClause(TypedDict, total=False):
col: str
op: str # pylint: disable=invalid-name
val: Optional[FilterValues]
grain: Optional[str]
isExtra: Optional[bool]
class ExtraFiltersTimeColumnType(str, Enum):
GRANULARITY = "__granularity"
TIME_COL = "__time_col"
@ -1017,28 +1036,32 @@ def zlib_decompress(blob: bytes, decode: Optional[bool] = True) -> Union[bytes,
return decompressed.decode("utf-8") if decode else decompressed
def to_adhoc(
filt: Dict[str, Any], expression_type: str = "SIMPLE", clause: str = "where"
) -> Dict[str, Any]:
result = {
def simple_filter_to_adhoc(
filter_clause: QueryObjectFilterClause, clause: str = "where",
) -> AdhocFilterClause:
result: AdhocFilterClause = {
"clause": clause.upper(),
"expressionType": expression_type,
"isExtra": bool(filt.get("isExtra")),
"expressionType": "SIMPLE",
"comparator": filter_clause.get("val"),
"operator": filter_clause["op"],
"subject": filter_clause["col"],
}
if filter_clause.get("isExtra"):
result["isExtra"] = True
result["filterOptionName"] = md5_sha_from_dict(cast(Dict[Any, Any], result))
if expression_type == "SIMPLE":
result.update(
{
"comparator": filt.get("val"),
"operator": filt.get("op"),
"subject": filt.get("col"),
}
)
elif expression_type == "SQL":
result.update({"sqlExpression": filt.get(clause)})
return result
deterministic_name = md5_sha_from_dict(result)
result["filterOptionName"] = deterministic_name
def form_data_to_adhoc(form_data: Dict[str, Any], clause: str) -> AdhocFilterClause:
if clause not in ("where", "having"):
raise ValueError(__("Unsupported clause type: %(clause)s", clause=clause))
result: AdhocFilterClause = {
"clause": clause.upper(),
"expressionType": "SQL",
"sqlExpression": form_data.get(clause),
}
result["filterOptionName"] = md5_sha_from_dict(cast(Dict[Any, Any], result))
return result
@ -1050,7 +1073,7 @@ def merge_extra_form_data(form_data: Dict[str, Any]) -> None:
"""
filter_keys = ["filters", "adhoc_filters"]
extra_form_data = form_data.pop("extra_form_data", {})
append_filters = extra_form_data.get("filters", None)
append_filters: List[QueryObjectFilterClause] = extra_form_data.get("filters", None)
# merge append extras
for key in [key for key in EXTRA_FORM_DATA_APPEND_KEYS if key not in filter_keys]:
@ -1075,13 +1098,21 @@ def merge_extra_form_data(form_data: Dict[str, Any]) -> None:
if extras:
form_data["extras"] = extras
adhoc_filters = form_data.get("adhoc_filters", [])
adhoc_filters: List[AdhocFilterClause] = form_data.get("adhoc_filters", [])
form_data["adhoc_filters"] = adhoc_filters
append_adhoc_filters = extra_form_data.get("adhoc_filters", [])
adhoc_filters.extend({"isExtra": True, **fltr} for fltr in append_adhoc_filters)
append_adhoc_filters: List[AdhocFilterClause] = extra_form_data.get(
"adhoc_filters", []
)
adhoc_filters.extend(
{"isExtra": True, **fltr} for fltr in append_adhoc_filters # type: ignore
)
if append_filters:
adhoc_filters.extend(
to_adhoc({"isExtra": True, **fltr}) for fltr in append_filters if fltr
simple_filter_to_adhoc(
{"isExtra": True, **fltr} # type: ignore
)
for fltr in append_filters
if fltr
)
@ -1148,16 +1179,16 @@ def merge_extra_filters( # pylint: disable=too-many-branches
# Add filters for unequal lists
# order doesn't matter
if set(existing_filters[filter_key]) != set(filtr["val"]):
adhoc_filters.append(to_adhoc(filtr))
adhoc_filters.append(simple_filter_to_adhoc(filtr))
else:
adhoc_filters.append(to_adhoc(filtr))
adhoc_filters.append(simple_filter_to_adhoc(filtr))
else:
# Do not add filter if same value already exists
if filtr["val"] != existing_filters[filter_key]:
adhoc_filters.append(to_adhoc(filtr))
adhoc_filters.append(simple_filter_to_adhoc(filtr))
else:
# Filter not found, add it
adhoc_filters.append(to_adhoc(filtr))
adhoc_filters.append(simple_filter_to_adhoc(filtr))
# Remove extra filters from the form data since no longer needed
del form_data["extra_filters"]
@ -1268,15 +1299,16 @@ def convert_legacy_filters_into_adhoc( # pylint: disable=invalid-name
mapping = {"having": "having_filters", "where": "filters"}
if not form_data.get("adhoc_filters"):
form_data["adhoc_filters"] = []
adhoc_filters: List[AdhocFilterClause] = []
form_data["adhoc_filters"] = adhoc_filters
for clause, filters in mapping.items():
if clause in form_data and form_data[clause] != "":
form_data["adhoc_filters"].append(to_adhoc(form_data, "SQL", clause))
adhoc_filters.append(form_data_to_adhoc(form_data, clause))
if filters in form_data:
for filt in filter(lambda x: x is not None, form_data[filters]):
form_data["adhoc_filters"].append(to_adhoc(filt, "SIMPLE", clause))
adhoc_filters.append(simple_filter_to_adhoc(filt, clause))
for key in ("filters", "having", "having_filters", "where"):
if key in form_data:

View File

@ -74,7 +74,7 @@ from superset.utils.core import (
JS_MAX_INTEGER,
merge_extra_filters,
QueryMode,
to_adhoc,
simple_filter_to_adhoc,
)
from superset.utils.date_parser import get_since_until, parse_past_timedelta
from superset.utils.dates import datetime_to_epoch
@ -2475,7 +2475,9 @@ class BaseDeckGLViz(BaseViz):
spatial_columns.add(line_column)
for column in sorted(spatial_columns):
filter_ = to_adhoc({"col": column, "op": "IS NOT NULL", "val": ""})
filter_ = simple_filter_to_adhoc(
{"col": column, "op": "IS NOT NULL", "val": ""}
)
fd["adhoc_filters"].append(filter_)
def query_obj(self) -> QueryObjectDict:

View File

@ -22,7 +22,6 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices,
)
import pandas
import pytest
from sqlalchemy.engine.url import make_url
@ -339,12 +338,19 @@ class TestDatabaseModel(SupersetTestCase):
class TestSqlaTableModel(SupersetTestCase):
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_get_timestamp_expression(self):
col_type = (
"VARCHAR"
if get_example_database().backend == "presto"
else "TemporalWrapperType"
)
tbl = self.get_table(name="birth_names")
ds_col = tbl.get_column("ds")
sqla_literal = ds_col.get_timestamp_expression(None)
self.assertEqual(str(sqla_literal.compile()), "ds")
assert type(sqla_literal.type).__name__ == col_type
sqla_literal = ds_col.get_timestamp_expression("P1D")
assert type(sqla_literal.type).__name__ == col_type
compiled = "{}".format(sqla_literal.compile())
if tbl.database.backend == "mysql":
self.assertEqual(compiled, "DATE(ds)")
@ -352,6 +358,7 @@ class TestSqlaTableModel(SupersetTestCase):
prev_ds_expr = ds_col.expression
ds_col.expression = "DATE_ADD(ds, 1)"
sqla_literal = ds_col.get_timestamp_expression("P1D")
assert type(sqla_literal.type).__name__ == col_type
compiled = "{}".format(sqla_literal.compile())
if tbl.database.backend == "mysql":
self.assertEqual(compiled, "DATE(DATE_ADD(ds, 1))")

View File

@ -17,10 +17,10 @@
# pylint: disable=no-self-use
import pytest
from superset.utils.core import to_adhoc
from superset.utils.core import form_data_to_adhoc, simple_filter_to_adhoc
def test_to_adhoc_generates_deterministic_values():
def test_simple_filter_to_adhoc_generates_deterministic_values():
input_1 = {
"op": "IS NOT NULL",
"col": "LATITUDE",
@ -30,25 +30,56 @@ def test_to_adhoc_generates_deterministic_values():
input_2 = {**input_1, "col": "LONGITUDE"}
# The result is the same when given the same input
assert to_adhoc(input_1) == to_adhoc(input_1)
assert to_adhoc(input_1) == {
assert simple_filter_to_adhoc(input_1) == simple_filter_to_adhoc(input_1)
assert simple_filter_to_adhoc(input_1) == {
"clause": "WHERE",
"expressionType": "SIMPLE",
"isExtra": False,
"comparator": "",
"operator": "IS NOT NULL",
"subject": "LATITUDE",
"filterOptionName": "d0908f77d950131db7a69fdc820cb739",
"filterOptionName": "6ac89d498115da22396f80a765cffc70",
}
# The result is different when given different input
assert to_adhoc(input_1) != to_adhoc(input_2)
assert to_adhoc(input_2) == {
assert simple_filter_to_adhoc(input_1) != simple_filter_to_adhoc(input_2)
assert simple_filter_to_adhoc(input_2) == {
"clause": "WHERE",
"expressionType": "SIMPLE",
"isExtra": False,
"comparator": "",
"operator": "IS NOT NULL",
"subject": "LONGITUDE",
"filterOptionName": "c5f283f727d4dfc6258b351d4a8663bc",
"filterOptionName": "9c984bd3714883ca859948354ce26ab9",
}
def test_form_data_to_adhoc_generates_deterministic_values():
form_data = {"where": "1 = 1", "having": "count(*) > 1"}
# The result is the same when given the same input
assert form_data_to_adhoc(form_data, "where") == form_data_to_adhoc(
form_data, "where"
)
assert form_data_to_adhoc(form_data, "where") == {
"clause": "WHERE",
"expressionType": "SQL",
"sqlExpression": "1 = 1",
"filterOptionName": "99fe79985afbddea4492626dc6a87b74",
}
# The result is different when given different input
assert form_data_to_adhoc(form_data, "having") == form_data_to_adhoc(
form_data, "having"
)
assert form_data_to_adhoc(form_data, "having") == {
"clause": "HAVING",
"expressionType": "SQL",
"sqlExpression": "count(*) > 1",
"filterOptionName": "1da11f6b709c3190daeabb84f77fc8c2",
}
def test_form_data_to_adhoc_incorrect_clause_type():
form_data = {"where": "1 = 1", "having": "count(*) > 1"}
with pytest.raises(ValueError):
form_data_to_adhoc(form_data, "foobar")

View File

@ -85,19 +85,6 @@ from tests.integration_tests.fixtures.world_bank_dashboard import (
from .fixtures.certificates import ssl_certificate
def mock_to_adhoc(filt, expressionType="SIMPLE", clause="where"):
result = {"clause": clause.upper(), "expressionType": expressionType}
if expressionType == "SIMPLE":
result.update(
{"comparator": filt["val"], "operator": filt["op"], "subject": filt["col"]}
)
elif expressionType == "SQL":
result.update({"sqlExpression": filt[clause]})
return result
class TestUtils(SupersetTestCase):
def test_json_int_dttm_ser(self):
dttm = datetime(2020, 1, 1)
@ -137,7 +124,6 @@ class TestUtils(SupersetTestCase):
got_str = zlib_decompress(blob)
self.assertEqual(json_str, got_str)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_merge_extra_filters(self):
# does nothing if no extra filters
form_data = {"A": 1, "B": 2, "c": "test"}
@ -168,6 +154,8 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": "someval",
"expressionType": "SIMPLE",
"filterOptionName": "90cfb3c34852eb3bc741b0cc20053b46",
"isExtra": True,
"operator": "in",
"subject": "a",
},
@ -175,6 +163,8 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": ["c1", "c2"],
"expressionType": "SIMPLE",
"filterOptionName": "6c178d069965f1c02640661280415d96",
"isExtra": True,
"operator": "==",
"subject": "B",
},
@ -212,6 +202,8 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": "someval",
"expressionType": "SIMPLE",
"filterOptionName": "90cfb3c34852eb3bc741b0cc20053b46",
"isExtra": True,
"operator": "in",
"subject": "a",
},
@ -219,6 +211,8 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": ["c1", "c2"],
"expressionType": "SIMPLE",
"filterOptionName": "6c178d069965f1c02640661280415d96",
"isExtra": True,
"operator": "==",
"subject": "B",
},
@ -244,6 +238,8 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": "hello",
"expressionType": "SIMPLE",
"filterOptionName": "e3cbdd92a2ae23ca92c6d7fca42e36a6",
"isExtra": True,
"operator": "like",
"subject": "A",
}
@ -264,7 +260,6 @@ class TestUtils(SupersetTestCase):
merge_extra_filters(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_merge_extra_filters_ignores_empty_filters(self):
form_data = {
"extra_filters": [
@ -276,7 +271,6 @@ class TestUtils(SupersetTestCase):
merge_extra_filters(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_merge_extra_filters_ignores_nones(self):
form_data = {
"adhoc_filters": [
@ -305,7 +299,6 @@ class TestUtils(SupersetTestCase):
merge_extra_filters(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_merge_extra_filters_ignores_equal_filters(self):
form_data = {
"extra_filters": [
@ -366,7 +359,6 @@ class TestUtils(SupersetTestCase):
merge_extra_filters(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_merge_extra_filters_merges_different_val_types(self):
form_data = {
"extra_filters": [
@ -410,6 +402,8 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": ["g1", "g2"],
"expressionType": "SIMPLE",
"filterOptionName": "c11969c994b40a83a4ae7d48ff1ea28e",
"isExtra": True,
"operator": "in",
"subject": "a",
},
@ -460,6 +454,8 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": "someval",
"expressionType": "SIMPLE",
"filterOptionName": "90cfb3c34852eb3bc741b0cc20053b46",
"isExtra": True,
"operator": "in",
"subject": "a",
},
@ -469,7 +465,6 @@ class TestUtils(SupersetTestCase):
merge_extra_filters(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_merge_extra_filters_adds_unequal_lists(self):
form_data = {
"extra_filters": [
@ -513,6 +508,8 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": ["g1", "g2", "g3"],
"expressionType": "SIMPLE",
"filterOptionName": "21cbb68af7b17e62b3b2f75e2190bfd7",
"isExtra": True,
"operator": "in",
"subject": "a",
},
@ -520,6 +517,8 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": ["c1", "c2", "c3"],
"expressionType": "SIMPLE",
"filterOptionName": "0a8dcb928f1f4bba97643c6e68d672f1",
"isExtra": True,
"operator": "==",
"subject": "B",
},
@ -580,18 +579,21 @@ class TestUtils(SupersetTestCase):
with self.assertRaises(SupersetException):
validate_json(invalid)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_convert_legacy_filters_into_adhoc_where(self):
form_data = {"where": "a = 1"}
expected = {
"adhoc_filters": [
{"clause": "WHERE", "expressionType": "SQL", "sqlExpression": "a = 1"}
{
"clause": "WHERE",
"expressionType": "SQL",
"filterOptionName": "46fb6d7891e23596e42ae38da94a57e0",
"sqlExpression": "a = 1",
}
]
}
convert_legacy_filters_into_adhoc(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_convert_legacy_filters_into_adhoc_filters(self):
form_data = {"filters": [{"col": "a", "op": "in", "val": "someval"}]}
expected = {
@ -600,6 +602,7 @@ class TestUtils(SupersetTestCase):
"clause": "WHERE",
"comparator": "someval",
"expressionType": "SIMPLE",
"filterOptionName": "135c7ee246666b840a3d7a9c3a30cf38",
"operator": "in",
"subject": "a",
}
@ -608,7 +611,6 @@ class TestUtils(SupersetTestCase):
convert_legacy_filters_into_adhoc(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_convert_legacy_filters_into_adhoc_having(self):
form_data = {"having": "COUNT(1) = 1"}
expected = {
@ -616,6 +618,7 @@ class TestUtils(SupersetTestCase):
{
"clause": "HAVING",
"expressionType": "SQL",
"filterOptionName": "683f1c26466ab912f75a00842e0f2f7b",
"sqlExpression": "COUNT(1) = 1",
}
]
@ -623,7 +626,6 @@ class TestUtils(SupersetTestCase):
convert_legacy_filters_into_adhoc(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_convert_legacy_filters_into_adhoc_having_filters(self):
form_data = {"having_filters": [{"col": "COUNT(1)", "op": "==", "val": 1}]}
expected = {
@ -632,6 +634,7 @@ class TestUtils(SupersetTestCase):
"clause": "HAVING",
"comparator": 1,
"expressionType": "SIMPLE",
"filterOptionName": "967d0fb409f6d9c7a6c03a46cf933c9c",
"operator": "==",
"subject": "COUNT(1)",
}
@ -640,18 +643,21 @@ class TestUtils(SupersetTestCase):
convert_legacy_filters_into_adhoc(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_convert_legacy_filters_into_adhoc_present_and_empty(self):
form_data = {"adhoc_filters": [], "where": "a = 1"}
expected = {
"adhoc_filters": [
{"clause": "WHERE", "expressionType": "SQL", "sqlExpression": "a = 1"}
{
"clause": "WHERE",
"expressionType": "SQL",
"filterOptionName": "46fb6d7891e23596e42ae38da94a57e0",
"sqlExpression": "a = 1",
}
]
}
convert_legacy_filters_into_adhoc(form_data)
self.assertEqual(form_data, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_convert_legacy_filters_into_adhoc_present_and_nonempty(self):
form_data = {
"adhoc_filters": [

View File

@ -1196,42 +1196,38 @@ class TestBaseDeckGLViz(SupersetTestCase):
{
"clause": "WHERE",
"expressionType": "SIMPLE",
"filterOptionName": "bfa3a42a6f3de3c781b7d4f8e8d6613d",
"filterOptionName": "c7f171cf3204bcbf456acfeac5cd9afd",
"comparator": "",
"operator": "IS NOT NULL",
"subject": "lat",
"isExtra": False,
},
{
"clause": "WHERE",
"expressionType": "SIMPLE",
"filterOptionName": "2d35d87b57c6f1a5ae139f1a6b0cbd0a",
"filterOptionName": "52634073fbb8ae0a3aa59ad48abac55e",
"comparator": "",
"operator": "IS NOT NULL",
"subject": "lon",
"isExtra": False,
},
],
"delimited_key": [
{
"clause": "WHERE",
"expressionType": "SIMPLE",
"filterOptionName": "89cc0fafe39a4eabc5df2cd52e4d6514",
"filterOptionName": "cae5c925c140593743da08499e6fb207",
"comparator": "",
"operator": "IS NOT NULL",
"subject": "lonlat",
"isExtra": False,
}
],
"geohash_key": [
{
"clause": "WHERE",
"expressionType": "SIMPLE",
"filterOptionName": "fa734d9a7bab254a53b41540d46cdb6c",
"filterOptionName": "d84f55222d8e414e888fa5f990b341d2",
"comparator": "",
"operator": "IS NOT NULL",
"subject": "geo",
"isExtra": False,
}
],
}