You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/08 00:08:48 UTC
(superset) branch master updated: feat(cache): warm up native filter option queries (#41531)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 98fc8576131 feat(cache): warm up native filter option queries (#41531)
98fc8576131 is described below
commit 98fc8576131cbbcabe9e4222898d2d13e13c14a6
Author: abdullah reveha <15...@users.noreply.github.com>
AuthorDate: Wed Jul 8 03:08:40 2026 +0300
feat(cache): warm up native filter option queries (#41531)
Co-authored-by: Abdullah Sahin <yo...@example.comclear>
---
superset/config.py | 29 ++
superset/tasks/cache.py | 208 +++++++++--
superset/tasks/native_filter_cache.py | 245 +++++++++++++
tests/unit_tests/tasks/test_cache.py | 288 ++++++++++++++-
tests/unit_tests/tasks/test_native_filter_cache.py | 405 +++++++++++++++++++++
5 files changed, 1132 insertions(+), 43 deletions(-)
diff --git a/superset/config.py b/superset/config.py
index 910322d7542..8df4bb3d4e9 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1216,6 +1216,35 @@ THUMBNAIL_COMPUTING_CACHE_TTL = int(timedelta(seconds=360).total_seconds())
# Celery task. Intentionally defaults to None so operators pick a dedicated
# least-privilege user rather than inadvertently running warmup as "admin".
SUPERSET_CACHE_WARMUP_USER: str | None = None
+# To warm up native filter option queries, use the `native_filter_options` strategy.
+# This strategy pre-populates the cache for Value-type native filter dropdowns by
+# executing the same chart-data queries the UI sends when a user opens a filter.
+#
+# Cache entries are warmed under SUPERSET_CACHE_WARMUP_USER. Without the shared
+# cache key optimization (not included in this PR), entries are scoped to the
+# warm-up user's normal cache partition. Users with different role sets may still
+# experience cache misses on first load.
+#
+# Example Celery beat configuration:
+#
+# beat_schedule = {
+# "cache-warmup-native-filters": {
+# "task": "cache-warmup",
+# "schedule": crontab(minute=0, hour=3), # daily at 03:00
+# "kwargs": {
+# "strategy_name": "native_filter_options",
+# "dashboard_ids": [1, 2, 3],
+# },
+# },
+# }
+#
+# Requirements:
+# - SUPERSET_CACHE_WARMUP_USER must be set to a user with access to the
+# dashboards and datasets referenced by the native filters.
+# - DATA_CACHE_CONFIG must be configured (Redis recommended).
+# - Celery beat must be running.
+# - Cascade/dependent filters and search-term variants are not warmed in this
+# version; only base option queries are supported.
# Time before selenium times out after trying to locate an element on the page and wait
# for that element to load for a screenshot.
diff --git a/superset/tasks/cache.py b/superset/tasks/cache.py
index a8aaaf38928..c994e735dc3 100644
--- a/superset/tasks/cache.py
+++ b/superset/tasks/cache.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import logging
+from dataclasses import dataclass
from typing import Any, Optional, Union
from celery.utils.log import get_task_logger
@@ -25,21 +26,37 @@ from sqlalchemy import and_, func
from sqlalchemy.orm import selectinload
from superset import db, security_manager
+from superset.common.query_context import QueryContext
+from superset.daos.dashboard import DashboardDAO
from superset.extensions import celery_app
from superset.models.core import Log
from superset.models.dashboard import Dashboard
from superset.tags.models import Tag, TaggedObject
+from superset.tasks.native_filter_cache import (
+ build_native_filter_option_form_data,
+ build_native_filter_option_query_context,
+ get_eligible_native_filters,
+)
from superset.utils.date_parser import parse_human_datetime
from superset.utils.webdriver import WebDriverSelenium
-logger = get_task_logger(__name__)
+logger: logging.Logger = get_task_logger(__name__)
logger.setLevel(logging.INFO)
+@dataclass(frozen=True)
+class CacheWarmupTask:
+ """A chart data query to execute for cache warm-up."""
+
+ query_context: QueryContext
+ dashboard_id: int
+ native_filter_id: str | None = None
+
+
def get_dash_url(dashboard: Dashboard) -> str:
"""Return external URL for warming up a given dashboard cache."""
with current_app.test_request_context():
- baseurl = (
+ baseurl: str = (
# when running this as an async task, drop the request context with
# app.test_request_context()
current_app.config.get("WEBDRIVER_BASEURL")
@@ -54,8 +71,8 @@ class Strategy: # pylint: disable=too-few-public-methods
"""
A cache warm up strategy.
- Each strategy defines a `get_urls` method that returns a list of dashboard URLs to
- warm up using WebDriver.
+ WebDriver strategies define a `get_urls` method that returns a list of
+ dashboard URLs to warm up. Query task strategies define `get_tasks`.
Strategies can be configured in `superset/config.py`:
@@ -73,12 +90,18 @@ class Strategy: # pylint: disable=too-few-public-methods
"""
+ name: str = ""
+ uses_webdriver: bool = True
+
def __init__(self) -> None:
pass
def get_urls(self) -> list[str]:
raise NotImplementedError("Subclasses must implement get_urls!")
+ def get_tasks(self) -> list[CacheWarmupTask]:
+ return []
+
class DummyStrategy(Strategy): # pylint: disable=too-few-public-methods
"""
@@ -97,11 +120,11 @@ class DummyStrategy(Strategy): # pylint: disable=too-few-public-methods
"""
- name = "dummy"
+ name: str = "dummy"
def get_urls(self) -> list[str]:
# Use selectinload to avoid N+1 queries when checking dashboard.slices
- dashboards = (
+ dashboards: list[Dashboard] = (
db.session.query(Dashboard)
.options(selectinload(Dashboard.slices))
.filter(Dashboard.published.is_(True))
@@ -129,15 +152,15 @@ class TopNDashboardsStrategy(Strategy): # pylint: disable=too-few-public-method
"""
- name = "top_n_dashboards"
+ name: str = "top_n_dashboards"
def __init__(self, top_n: int = 5, since: str = "7 days ago") -> None:
super().__init__()
- self.top_n = top_n
- self.since = parse_human_datetime(since) if since else None
+ self.top_n: int = top_n
+ self.since: Any = parse_human_datetime(since) if since else None
def get_urls(self) -> list[str]:
- records = (
+ records: list[Any] = (
db.session.query(Log.dashboard_id, func.count(Log.dashboard_id))
.filter(and_(Log.dashboard_id.isnot(None), Log.dttm >= self.since))
.group_by(Log.dashboard_id)
@@ -145,8 +168,8 @@ class TopNDashboardsStrategy(Strategy): # pylint: disable=too-few-public-method
.limit(self.top_n)
.all()
)
- dash_ids = [record.dashboard_id for record in records]
- dashboards = (
+ dash_ids: list[int] = [record.dashboard_id for record in records]
+ dashboards: list[Dashboard] = (
db.session.query(Dashboard).filter(Dashboard.id.in_(dash_ids)).all()
)
@@ -169,19 +192,19 @@ class DashboardTagsStrategy(Strategy): # pylint: disable=too-few-public-methods
}
"""
- name = "dashboard_tags"
+ name: str = "dashboard_tags"
def __init__(self, tags: Optional[list[str]] = None) -> None:
super().__init__()
- self.tags = tags or []
+ self.tags: list[str] = tags or []
def get_urls(self) -> list[str]:
- urls = []
- tags = db.session.query(Tag).filter(Tag.name.in_(self.tags)).all()
- tag_ids = [tag.id for tag in tags]
+ urls: list[str] = []
+ tags: list[Tag] = db.session.query(Tag).filter(Tag.name.in_(self.tags)).all()
+ tag_ids: list[int] = [tag.id for tag in tags]
# add dashboards that are tagged
- tagged_objects = (
+ tagged_objects: list[TaggedObject] = (
db.session.query(TaggedObject)
.filter(
and_(
@@ -191,8 +214,10 @@ class DashboardTagsStrategy(Strategy): # pylint: disable=too-few-public-methods
)
.all()
)
- dash_ids = [tagged_object.object_id for tagged_object in tagged_objects]
- tagged_dashboards = db.session.query(Dashboard).filter(
+ dash_ids: list[int] = [
+ tagged_object.object_id for tagged_object in tagged_objects
+ ]
+ tagged_dashboards: list[Dashboard] = db.session.query(Dashboard).filter(
Dashboard.id.in_(dash_ids)
)
for dashboard in tagged_dashboards:
@@ -201,7 +226,105 @@ class DashboardTagsStrategy(Strategy): # pylint: disable=too-few-public-methods
return urls
-strategies = [DummyStrategy, TopNDashboardsStrategy, DashboardTagsStrategy]
+class NativeFilterOptionsStrategy(Strategy): # pylint: disable=too-few-public-methods
+ """
+ Build chart data query tasks for native filter option cache warm-up.
+ """
+
+ name: str = "native_filter_options"
+ uses_webdriver: bool = False
+
+ def __init__(self, dashboard_ids: list[int]) -> None:
+ super().__init__()
+ self.dashboard_ids: list[int] = dashboard_ids
+
+ def get_urls(self) -> list[str]:
+ return []
+
+ def get_tasks(self) -> list[CacheWarmupTask]:
+ tasks: list[CacheWarmupTask] = []
+
+ for dashboard_id in self.dashboard_ids:
+ skipped: int = 0
+ built: int = 0
+
+ try:
+ dashboard: Dashboard | None = DashboardDAO.find_by_id(dashboard_id)
+ if dashboard is None:
+ logger.warning(
+ "Dashboard %s not found; skipping native filter option "
+ "cache warm-up",
+ dashboard_id,
+ )
+ continue
+
+ filter_configs: list[dict[str, Any]] = get_eligible_native_filters(
+ dashboard
+ )
+
+ for filter_config in filter_configs:
+ try:
+ form_data: dict[str, Any] | None = (
+ build_native_filter_option_form_data(
+ dashboard,
+ filter_config,
+ )
+ )
+ if form_data is None:
+ skipped += 1
+ continue
+
+ query_context: QueryContext | None = (
+ build_native_filter_option_query_context(form_data)
+ )
+ if query_context is None:
+ skipped += 1
+ continue
+
+ tasks.append(
+ CacheWarmupTask(
+ query_context=query_context,
+ dashboard_id=dashboard.id,
+ native_filter_id=filter_config.get("id"),
+ )
+ )
+ built += 1
+ except Exception: # noqa: BLE001
+ skipped += 1
+ logger.exception(
+ "Error building native filter option cache warm-up "
+ "task for dashboard %s filter %s",
+ dashboard_id,
+ filter_config.get("id"),
+ )
+
+ logger.info(
+ "Dashboard %s native filter option cache warm-up: %s filters "
+ "found, %s tasks built, %s skipped",
+ dashboard_id,
+ len(filter_configs),
+ built,
+ skipped,
+ )
+ except Exception: # noqa: BLE001
+ logger.exception(
+ "Error building native filter option cache warm-up tasks for "
+ "dashboard %s",
+ dashboard_id,
+ )
+
+ return tasks
+
+
+strategies: list[type[Strategy]] = [
+ DummyStrategy,
+ TopNDashboardsStrategy,
+ DashboardTagsStrategy,
+ NativeFilterOptionsStrategy,
+]
+strategy_registry: dict[str, type[Strategy]] = {
+ strategy.name: strategy for strategy in strategies
+}
@celery_app.task(name="cache-warmup")
@@ -215,18 +338,15 @@ def cache_warmup(
"""
logger.info("Loading strategy")
- class_ = None
- for class_ in strategies:
- if class_.name == strategy_name: # type: ignore
- break
- else:
- message = "No strategy %s found!" % strategy_name
+ class_: type[Strategy] | None = strategy_registry.get(strategy_name)
+ if class_ is None:
+ message: str = "No strategy %s found!" % strategy_name
logger.error(message, exc_info=True)
return message
logger.info("Loading %s", class_.__name__)
try:
- strategy = class_(*args, **kwargs)
+ strategy: Strategy = class_(*args, **kwargs)
logger.info("Success!")
except TypeError:
message = "Error loading strategy!"
@@ -235,7 +355,7 @@ def cache_warmup(
results: dict[str, list[str]] = {"success": [], "errors": []}
- warmup_username = current_app.config.get("SUPERSET_CACHE_WARMUP_USER")
+ warmup_username: str | None = current_app.config.get("SUPERSET_CACHE_WARMUP_USER")
if not warmup_username:
message = (
"SUPERSET_CACHE_WARMUP_USER is not configured. Set it to a dedicated "
@@ -244,7 +364,7 @@ def cache_warmup(
logger.error(message)
return message
- user = security_manager.find_user(username=warmup_username)
+ user: Any = security_manager.find_user(username=warmup_username)
if not user:
message = (
f"Cache warmup user '{warmup_username}' not found. Please configure "
@@ -253,7 +373,33 @@ def cache_warmup(
logger.error(message)
return message
- wd = WebDriverSelenium(current_app.config["WEBDRIVER_TYPE"], user=user)
+ if not strategy.uses_webdriver:
+ # pylint: disable=import-outside-toplevel
+ from superset.commands.chart.data.get_data_command import ChartDataCommand
+ from superset.utils.core import override_user
+
+ with override_user(user, force=False):
+ tasks: list[CacheWarmupTask] = strategy.get_tasks()
+ for task in tasks:
+ task_name: str = (
+ f"dashboard:{task.dashboard_id}:"
+ f"native_filter:{task.native_filter_id}"
+ )
+ try:
+ logger.info("Warming up cache for %s", task_name)
+ command: Any = ChartDataCommand(task.query_context)
+ command.validate()
+ command.run(cache=True)
+ results["success"].append(task_name)
+ except Exception: # noqa: BLE001
+ logger.exception("Error warming up cache for %s", task_name)
+ results["errors"].append(task_name)
+
+ return results
+
+ wd: WebDriverSelenium = WebDriverSelenium(
+ current_app.config["WEBDRIVER_TYPE"], user=user
+ )
try:
for url in strategy.get_urls():
diff --git a/superset/tasks/native_filter_cache.py b/superset/tasks/native_filter_cache.py
new file mode 100644
index 00000000000..54fe530a062
--- /dev/null
+++ b/superset/tasks/native_filter_cache.py
@@ -0,0 +1,245 @@
+# 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.
+from __future__ import annotations
+
+import logging
+from typing import Any, Final
+
+from marshmallow import ValidationError
+
+from superset.charts.schemas import ChartDataQueryContextSchema
+from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
+from superset.common.query_context import QueryContext
+from superset.models.dashboard import Dashboard
+from superset.utils import json
+from superset.utils.core import DatasourceType, split_adhoc_filters_into_base_filters
+
+logger: logging.Logger = logging.getLogger(__name__)
+
+NATIVE_FILTER_DEFAULT_ROW_LIMIT: Final[int] = 1000
+NO_FILTER_TIME_RANGE: Final[str] = "No filter"
+
+
+def _resolve_datasource_engine(datasource_id: int) -> str:
+ """Return the datasource engine name, or ``"base"`` if it cannot be resolved."""
+ try:
+ # pylint: disable=import-outside-toplevel
+ from superset.daos.datasource import DatasourceDAO
+
+ datasource: Any = DatasourceDAO.get_datasource(
+ datasource_type=DatasourceType.TABLE,
+ database_id_or_uuid=datasource_id,
+ )
+ return datasource.database.db_engine_spec.engine
+ except Exception: # noqa: BLE001
+ logger.debug("Could not resolve engine for datasource %s", datasource_id)
+ return "base"
+
+
+def _get_filter_target(filter_config: dict[str, Any]) -> tuple[int | str, str] | None:
+ for target in filter_config.get("targets") or []:
+ if not isinstance(target, dict):
+ continue
+
+ dataset_id: Any = target.get("datasetId")
+ column: Any = target.get("column") or {}
+ column_name: str | None = (
+ column.get("name") if isinstance(column, dict) else None
+ )
+
+ if dataset_id is not None and column_name:
+ return dataset_id, column_name
+
+ return None
+
+
+def get_eligible_native_filters(dashboard: Dashboard) -> list[dict[str, Any]]:
+ """Return native filter value filters eligible for option cache warm-up."""
+ if not dashboard.json_metadata:
+ logger.warning(
+ "Dashboard %s has no json_metadata; skipping native filter warm-up",
+ dashboard.id,
+ )
+ return []
+
+ try:
+ metadata: dict[str, Any] = json.loads(dashboard.json_metadata)
+ except (TypeError, ValueError):
+ logger.warning(
+ "Dashboard %s has malformed json_metadata; skipping native filter warm-up",
+ dashboard.id,
+ )
+ return []
+
+ native_filter_config: Any = metadata.get("native_filter_configuration")
+ if not isinstance(native_filter_config, list):
+ logger.warning(
+ "Dashboard %s has no native_filter_configuration; skipping native "
+ "filter warm-up",
+ dashboard.id,
+ )
+ return []
+
+ logger.debug(
+ "Dashboard %s native_filter_configuration count: %s",
+ dashboard.id,
+ len(native_filter_config),
+ )
+
+ eligible_filters: list[dict[str, Any]] = []
+ for filter_config in native_filter_config:
+ if not isinstance(filter_config, dict):
+ continue
+ if filter_config.get("type") == "DIVIDER":
+ continue
+ if filter_config.get("filterType") != "filter_select":
+ continue
+ if _get_filter_target(filter_config) is None:
+ continue
+
+ eligible_filters.append(filter_config)
+
+ logger.debug(
+ "Dashboard %s eligible filters: %s",
+ dashboard.id,
+ [filter_config.get("id") for filter_config in eligible_filters],
+ )
+
+ return eligible_filters
+
+
+def build_native_filter_option_form_data(
+ dashboard: Dashboard,
+ filter_config: dict[str, Any],
+) -> dict[str, Any] | None:
+ """Build form data for a native filter option query."""
+ target: tuple[int | str, str] | None = _get_filter_target(filter_config)
+ if target is None:
+ logger.warning(
+ "Native filter %s on dashboard %s has no valid target; skipping",
+ filter_config.get("id"),
+ dashboard.id,
+ )
+ return None
+
+ dataset_id: int | str
+ column_name: str
+ dataset_id, column_name = target
+ control_values: dict[str, Any] = filter_config.get("controlValues") or {}
+
+ return {
+ "datasource": f"{dataset_id}__table",
+ "viz_type": "filter_select",
+ "type": "NATIVE_FILTER",
+ "native_filter_id": filter_config["id"],
+ "dashboardId": dashboard.id,
+ "groupby": [column_name],
+ "adhoc_filters": filter_config.get("adhoc_filters", []),
+ "extra_filters": [],
+ "extra_form_data": {},
+ "metrics": ["count"],
+ "row_limit": NATIVE_FILTER_DEFAULT_ROW_LIMIT,
+ "time_range": filter_config.get("time_range") or NO_FILTER_TIME_RANGE,
+ "granularity_sqla": filter_config.get("granularity_sqla"),
+ "showSearch": True,
+ "sortAscending": control_values.get("sortAscending", True),
+ "sortMetric": filter_config.get("sortMetric", None),
+ }
+
+
+def build_native_filter_option_query_context(
+ form_data: dict[str, Any],
+) -> QueryContext | None:
+ """Build a query context for a native filter option query."""
+ datasource: Any = form_data.get("datasource")
+ groupby: Any = form_data.get("groupby") or []
+ column_name: str | None = (
+ groupby[0]
+ if isinstance(groupby, list) and groupby and isinstance(groupby[0], str)
+ else None
+ )
+
+ if not datasource or not column_name:
+ logger.warning(
+ "Invalid native filter option form_data; datasource and groupby are "
+ "required"
+ )
+ return None
+
+ try:
+ datasource_id: int = int(str(datasource).split("__", 1)[0])
+ except (TypeError, ValueError):
+ logger.warning(
+ "Invalid native filter option datasource %r; skipping query context",
+ datasource,
+ )
+ return None
+
+ sort_metric: str | None = form_data.get("sortMetric")
+ sort_ascending: bool = form_data.get("sortAscending", True)
+ metrics: list[str] = [sort_metric] if sort_metric else []
+ orderby: list[list[str | bool]] = [
+ [sort_metric or column_name, bool(sort_ascending)]
+ ]
+ adhoc_filters: list[Any] = form_data.get("adhoc_filters") or []
+ has_sql_filter: bool = any(
+ isinstance(filter_, dict) and filter_.get("expressionType") == "SQL"
+ for filter_ in adhoc_filters
+ )
+ engine: str = (
+ _resolve_datasource_engine(datasource_id) if has_sql_filter else "base"
+ )
+ split_adhoc_filters_into_base_filters(form_data, engine)
+
+ payload: dict[str, Any] = {
+ "datasource": {
+ "id": datasource_id,
+ "type": DatasourceType.TABLE,
+ },
+ "force": False,
+ "queries": [
+ {
+ "groupby": [column_name],
+ "filters": form_data.get("filters", []),
+ "extras": {},
+ "metrics": metrics,
+ "orderby": orderby,
+ "row_limit": form_data.get(
+ "row_limit",
+ NATIVE_FILTER_DEFAULT_ROW_LIMIT,
+ ),
+ "time_range": form_data.get("time_range", NO_FILTER_TIME_RANGE),
+ }
+ ],
+ "form_data": form_data,
+ "result_format": ChartDataResultFormat.JSON,
+ "result_type": ChartDataResultType.FULL,
+ }
+
+ try:
+ logger.debug(
+ "Built query context payload for datasource=%s groupby=%s",
+ datasource_id,
+ column_name,
+ )
+ return ChartDataQueryContextSchema().load(payload)
+ except (KeyError, ValidationError, ValueError) as ex:
+ logger.warning(
+ "Unable to build native filter option query context: %s",
+ ex,
+ )
+ return None
diff --git a/tests/unit_tests/tasks/test_cache.py b/tests/unit_tests/tasks/test_cache.py
index 53871d4fd4d..aebaa22725a 100644
--- a/tests/unit_tests/tasks/test_cache.py
+++ b/tests/unit_tests/tasks/test_cache.py
@@ -23,7 +23,7 @@ def _fake_app(config: Optional[dict[str, Any]] = None) -> mock.MagicMock:
base: dict[str, Any] = {"WEBDRIVER_TYPE": "chrome"}
if config:
base.update(config)
- app = mock.MagicMock()
+ app: mock.MagicMock = mock.MagicMock()
app.config = base
return app
@@ -33,7 +33,7 @@ def test_cache_warmup_unknown_strategy(app_context: None) -> None:
from superset.tasks.cache import cache_warmup
with mock.patch("superset.tasks.cache.WebDriverSelenium") as mock_wd:
- result = cache_warmup("does_not_exist")
+ result: dict[str, list[str]] | str = cache_warmup("does_not_exist")
assert result == "No strategy does_not_exist found!"
mock_wd.assert_not_called()
@@ -50,7 +50,7 @@ def test_cache_warmup_missing_config(app_context: None) -> None:
),
mock.patch("superset.tasks.cache.WebDriverSelenium") as mock_wd,
):
- result = cache_warmup("dummy")
+ result: dict[str, list[str]] | str = cache_warmup("dummy")
assert isinstance(result, str)
assert "SUPERSET_CACHE_WARMUP_USER is not configured" in result
@@ -70,7 +70,7 @@ def test_cache_warmup_user_not_found(app_context: None) -> None:
mock.patch("superset.tasks.cache.WebDriverSelenium") as mock_wd,
):
mock_sm.find_user = mock.MagicMock(return_value=None)
- result = cache_warmup("dummy")
+ result: dict[str, list[str]] | str = cache_warmup("dummy")
assert isinstance(result, str)
assert "not found" in result
@@ -82,8 +82,8 @@ def test_cache_warmup_happy_path(app_context: None) -> None:
"""Each URL is screenshotted as the configured user and the driver is destroyed."""
from superset.tasks.cache import cache_warmup
- urls = ["http://localhost/dash/1", "http://localhost/dash/2"]
- user = mock.MagicMock()
+ urls: list[str] = ["http://localhost/dash/1", "http://localhost/dash/2"]
+ user: mock.MagicMock = mock.MagicMock()
with (
mock.patch(
@@ -95,8 +95,8 @@ def test_cache_warmup_happy_path(app_context: None) -> None:
mock.patch("superset.tasks.cache.DummyStrategy.get_urls", return_value=urls),
):
mock_sm.find_user = mock.MagicMock(return_value=user)
- driver = mock_wd.return_value
- result = cache_warmup("dummy")
+ driver: mock.MagicMock = mock_wd.return_value
+ result: dict[str, list[str]] | str = cache_warmup("dummy")
assert result == {"success": urls, "errors": []}
mock_wd.assert_called_once_with("chrome", user=user)
@@ -108,8 +108,8 @@ def test_cache_warmup_collects_errors_and_destroys(app_context: None) -> None:
"""A failing URL is recorded as an error and cleanup still runs in finally."""
from superset.tasks.cache import cache_warmup
- urls = ["http://localhost/dash/ok", "http://localhost/dash/boom"]
- user = mock.MagicMock()
+ urls: list[str] = ["http://localhost/dash/ok", "http://localhost/dash/boom"]
+ user: mock.MagicMock = mock.MagicMock()
def side_effect(url: str, _element: str) -> None:
if url.endswith("boom"):
@@ -125,12 +125,276 @@ def test_cache_warmup_collects_errors_and_destroys(app_context: None) -> None:
mock.patch("superset.tasks.cache.DummyStrategy.get_urls", return_value=urls),
):
mock_sm.find_user = mock.MagicMock(return_value=user)
- driver = mock_wd.return_value
+ driver: mock.MagicMock = mock_wd.return_value
driver.get_screenshot.side_effect = side_effect
- result = cache_warmup("dummy")
+ result: dict[str, list[str]] | str = cache_warmup("dummy")
assert result == {
"success": ["http://localhost/dash/ok"],
"errors": ["http://localhost/dash/boom"],
}
driver.destroy.assert_called_once_with()
+
+
+def test_native_filter_options_strategy_returns_tasks_for_eligible_filters() -> None:
+ """Eligible native filters are converted into cache warm-up tasks."""
+ from superset.tasks.cache import NativeFilterOptionsStrategy
+
+ dashboard: mock.MagicMock = mock.MagicMock()
+ dashboard.id = 10
+ filter_configs: list[dict[str, Any]] = [{"id": "filter-1"}, {"id": "filter-2"}]
+ form_data: list[dict[str, Any]] = [
+ {"groupby": ["country"]},
+ {"groupby": ["state"]},
+ ]
+ query_contexts: list[mock.MagicMock] = [mock.MagicMock(), mock.MagicMock()]
+
+ with (
+ mock.patch(
+ "superset.tasks.cache.DashboardDAO.find_by_id",
+ return_value=dashboard,
+ ),
+ mock.patch(
+ "superset.tasks.cache.get_eligible_native_filters",
+ return_value=filter_configs,
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_form_data",
+ side_effect=form_data,
+ ) as mock_build_form_data,
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_query_context",
+ side_effect=query_contexts,
+ ) as mock_build_query_context,
+ ):
+ tasks: list[Any] = NativeFilterOptionsStrategy(dashboard_ids=[10]).get_tasks()
+
+ assert len(tasks) == 2
+ assert [task.query_context for task in tasks] == query_contexts
+ assert [task.dashboard_id for task in tasks] == [10, 10]
+ assert [task.native_filter_id for task in tasks] == ["filter-1", "filter-2"]
+ assert mock_build_form_data.call_count == 2
+ assert mock_build_query_context.call_count == 2
+
+
+def test_native_filter_options_strategy_skips_missing_dashboard() -> None:
+ """Missing dashboards are skipped without raising."""
+ from superset.tasks.cache import NativeFilterOptionsStrategy
+
+ with (
+ mock.patch(
+ "superset.tasks.cache.DashboardDAO.find_by_id",
+ return_value=None,
+ ),
+ mock.patch("superset.tasks.cache.get_eligible_native_filters") as mock_filters,
+ ):
+ tasks: list[Any] = NativeFilterOptionsStrategy(dashboard_ids=[10]).get_tasks()
+
+ assert tasks == []
+ mock_filters.assert_not_called()
+
+
+def test_native_filter_options_strategy_skips_when_form_data_is_none() -> None:
+ """Filters without form data are skipped without raising."""
+ from superset.tasks.cache import NativeFilterOptionsStrategy
+
+ dashboard: mock.MagicMock = mock.MagicMock()
+ dashboard.id = 10
+ filter_configs: list[dict[str, Any]] = [{"id": "filter-1"}, {"id": "filter-2"}]
+ query_context: mock.MagicMock = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "superset.tasks.cache.DashboardDAO.find_by_id",
+ return_value=dashboard,
+ ),
+ mock.patch(
+ "superset.tasks.cache.get_eligible_native_filters",
+ return_value=filter_configs,
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_form_data",
+ side_effect=[None, {"groupby": ["country"]}],
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_query_context",
+ return_value=query_context,
+ ) as mock_build_query_context,
+ ):
+ tasks: list[Any] = NativeFilterOptionsStrategy(dashboard_ids=[10]).get_tasks()
+
+ assert len(tasks) == 1
+ assert tasks[0].query_context == query_context
+ mock_build_query_context.assert_called_once_with({"groupby": ["country"]})
+
+
+def test_native_filter_options_strategy_skips_when_query_context_is_none() -> None:
+ """Filters without a query context are skipped without raising."""
+ from superset.tasks.cache import NativeFilterOptionsStrategy
+
+ dashboard: mock.MagicMock = mock.MagicMock()
+ dashboard.id = 10
+
+ with (
+ mock.patch(
+ "superset.tasks.cache.DashboardDAO.find_by_id",
+ return_value=dashboard,
+ ),
+ mock.patch(
+ "superset.tasks.cache.get_eligible_native_filters",
+ return_value=[{"id": "filter-1"}],
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_form_data",
+ return_value={"groupby": ["country"]},
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_query_context",
+ return_value=None,
+ ),
+ ):
+ tasks: list[Any] = NativeFilterOptionsStrategy(dashboard_ids=[10]).get_tasks()
+
+ assert tasks == []
+
+
+def test_native_filter_options_strategy_uses_real_filter_helpers() -> None:
+ """Eligible filter config is handled by real helper functions."""
+ from superset.tasks.cache import NativeFilterOptionsStrategy
+ from superset.utils import json
+
+ dashboard: mock.MagicMock = mock.MagicMock()
+ dashboard.id = 10
+ dashboard.json_metadata = json.dumps(
+ {
+ "native_filter_configuration": [
+ "bad-entry",
+ {
+ "id": "filter-1",
+ "filterType": "filter_select",
+ "type": "NATIVE_FILTER",
+ "targets": [{"datasetId": 7, "column": {"name": "country"}}],
+ "adhoc_filters": [
+ {
+ "expressionType": "SIMPLE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ ],
+ },
+ {
+ "id": "range-filter",
+ "filterType": "filter_range",
+ "type": "NATIVE_FILTER",
+ "targets": [{"datasetId": 7, "column": {"name": "value"}}],
+ },
+ ]
+ }
+ )
+ query_context: mock.MagicMock = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "superset.tasks.cache.DashboardDAO.find_by_id",
+ return_value=dashboard,
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_query_context",
+ return_value=query_context,
+ ) as mock_build_query_context,
+ ):
+ tasks: list[Any] = NativeFilterOptionsStrategy(dashboard_ids=[10]).get_tasks()
+
+ assert len(tasks) == 1
+ assert tasks[0].dashboard_id == 10
+ assert tasks[0].native_filter_id == "filter-1"
+ assert tasks[0].query_context == query_context
+ mock_build_query_context.assert_called_once()
+ form_data: dict[str, Any] = mock_build_query_context.call_args.args[0]
+ assert form_data["datasource"] == "7__table"
+ assert form_data["groupby"] == ["country"]
+ assert form_data["adhoc_filters"] == [
+ {
+ "expressionType": "SIMPLE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ ]
+
+
+def test_native_filter_options_strategy_skips_filter_helper_exceptions() -> None:
+ """A failing filter config is skipped while remaining filters are processed."""
+ from superset.tasks.cache import NativeFilterOptionsStrategy
+
+ dashboard: mock.MagicMock = mock.MagicMock()
+ dashboard.id = 10
+ filter_configs: list[dict[str, Any]] = [{"missing_id": True}, {"id": "filter-2"}]
+ query_context: mock.MagicMock = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "superset.tasks.cache.DashboardDAO.find_by_id",
+ return_value=dashboard,
+ ),
+ mock.patch(
+ "superset.tasks.cache.get_eligible_native_filters",
+ return_value=filter_configs,
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_form_data",
+ side_effect=[RuntimeError("boom"), {"groupby": ["country"]}],
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_query_context",
+ return_value=query_context,
+ ),
+ ):
+ tasks: list[Any] = NativeFilterOptionsStrategy(dashboard_ids=[10]).get_tasks()
+
+ assert len(tasks) == 1
+ assert tasks[0].native_filter_id == "filter-2"
+ assert tasks[0].query_context == query_context
+
+
+def test_native_filter_options_strategy_skips_dashboard_lookup_exceptions() -> None:
+ """A dashboard-level failure is caught and does not stop other dashboard ids."""
+ from superset.tasks.cache import NativeFilterOptionsStrategy
+
+ dashboard: mock.MagicMock = mock.MagicMock()
+ dashboard.id = 11
+ query_context: mock.MagicMock = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "superset.tasks.cache.DashboardDAO.find_by_id",
+ side_effect=[RuntimeError("boom"), dashboard],
+ ),
+ mock.patch(
+ "superset.tasks.cache.get_eligible_native_filters",
+ return_value=[{"id": "filter-1"}],
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_form_data",
+ return_value={"groupby": ["country"]},
+ ),
+ mock.patch(
+ "superset.tasks.cache.build_native_filter_option_query_context",
+ return_value=query_context,
+ ),
+ ):
+ tasks: list[Any] = NativeFilterOptionsStrategy(
+ dashboard_ids=[10, 11]
+ ).get_tasks()
+
+ assert len(tasks) == 1
+ assert tasks[0].dashboard_id == 11
+ assert tasks[0].native_filter_id == "filter-1"
+
+
+def test_native_filter_options_strategy_registered() -> None:
+ """The native filter options strategy is registered by name."""
+ from superset.tasks.cache import NativeFilterOptionsStrategy, strategy_registry
+
+ assert strategy_registry["native_filter_options"] is NativeFilterOptionsStrategy
diff --git a/tests/unit_tests/tasks/test_native_filter_cache.py b/tests/unit_tests/tasks/test_native_filter_cache.py
new file mode 100644
index 00000000000..a3ae29ca51d
--- /dev/null
+++ b/tests/unit_tests/tasks/test_native_filter_cache.py
@@ -0,0 +1,405 @@
+# 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.
+from __future__ import annotations
+
+from typing import Any
+from unittest import mock
+
+from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
+from superset.common.query_context import QueryContext
+from superset.tasks.native_filter_cache import (
+ build_native_filter_option_form_data,
+ build_native_filter_option_query_context,
+ get_eligible_native_filters,
+)
+from superset.utils import json
+
+
+def _dashboard(json_metadata: str | None, dashboard_id: int = 1) -> mock.MagicMock:
+ dashboard: mock.MagicMock = mock.MagicMock()
+ dashboard.id = dashboard_id
+ dashboard.json_metadata = json_metadata
+ return dashboard
+
+
+def _filter_config(
+ filter_id: str = "filter-1",
+ filter_type: str = "filter_select",
+ entry_type: str = "NATIVE_FILTER",
+ targets: list[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+ return {
+ "id": filter_id,
+ "filterType": filter_type,
+ "type": entry_type,
+ "targets": targets
+ if targets is not None
+ else [{"datasetId": 10, "column": {"name": "country"}}],
+ }
+
+
+def test_get_eligible_native_filters_returns_only_filter_select() -> None:
+ filter_select: dict[str, Any] = _filter_config(filter_id="select")
+ metadata: dict[str, Any] = {
+ "native_filter_configuration": [
+ filter_select,
+ _filter_config(filter_id="range", filter_type="filter_range"),
+ _filter_config(filter_id="divider", entry_type="DIVIDER"),
+ ]
+ }
+
+ result: list[dict[str, Any]] = get_eligible_native_filters(
+ _dashboard(json.dumps(metadata))
+ )
+
+ assert result == [filter_select]
+
+
+def test_get_eligible_native_filters_skips_non_dict_entries() -> None:
+ valid_filter: dict[str, Any] = _filter_config(filter_id="valid")
+ metadata: dict[str, Any] = {
+ "native_filter_configuration": [
+ "not-a-filter",
+ valid_filter,
+ None,
+ ["also", "not", "a", "filter"],
+ ]
+ }
+
+ result: list[dict[str, Any]] = get_eligible_native_filters(
+ _dashboard(json.dumps(metadata))
+ )
+
+ assert result == [valid_filter]
+
+
+def test_get_eligible_native_filters_skips_missing_target() -> None:
+ metadata: dict[str, Any] = {
+ "native_filter_configuration": [
+ _filter_config(targets=[]),
+ ]
+ }
+
+ result: list[dict[str, Any]] = get_eligible_native_filters(
+ _dashboard(json.dumps(metadata))
+ )
+
+ assert result == []
+
+
+def test_get_eligible_native_filters_skips_missing_column() -> None:
+ metadata: dict[str, Any] = {
+ "native_filter_configuration": [
+ _filter_config(targets=[{"datasetId": 10, "column": {}}]),
+ ]
+ }
+
+ result: list[dict[str, Any]] = get_eligible_native_filters(
+ _dashboard(json.dumps(metadata))
+ )
+
+ assert result == []
+
+
+def test_get_eligible_native_filters_malformed_json_metadata() -> None:
+ result: list[dict[str, Any]] = get_eligible_native_filters(_dashboard("{"))
+
+ assert result == []
+
+
+def test_get_eligible_native_filters_missing_native_filter_configuration() -> None:
+ result: list[dict[str, Any]] = get_eligible_native_filters(
+ _dashboard(json.dumps({"label": "dash"}))
+ )
+
+ assert result == []
+
+
+def test_build_native_filter_option_form_data_correct_shape() -> None:
+ dashboard: mock.MagicMock = _dashboard(json_metadata=None, dashboard_id=42)
+ filter_config: dict[str, Any] = {
+ **_filter_config(filter_id="native-filter-1"),
+ "adhoc_filters": [{"expressionType": "SIMPLE"}],
+ "controlValues": {"sortAscending": False},
+ "sortMetric": "sum__value",
+ }
+
+ result: dict[str, Any] | None = build_native_filter_option_form_data(
+ dashboard, filter_config
+ )
+
+ assert result == {
+ "datasource": "10__table",
+ "viz_type": "filter_select",
+ "type": "NATIVE_FILTER",
+ "native_filter_id": "native-filter-1",
+ "dashboardId": 42,
+ "groupby": ["country"],
+ "adhoc_filters": [{"expressionType": "SIMPLE"}],
+ "extra_filters": [],
+ "extra_form_data": {},
+ "metrics": ["count"],
+ "row_limit": 1000,
+ "time_range": "No filter",
+ "granularity_sqla": None,
+ "showSearch": True,
+ "sortAscending": False,
+ "sortMetric": "sum__value",
+ }
+
+
+def test_build_native_filter_option_form_data_uses_filter_level_prefilters() -> None:
+ filter_config: dict[str, Any] = {
+ **_filter_config(),
+ "time_range": "Last week",
+ "granularity_sqla": "ds",
+ }
+
+ result: dict[str, Any] | None = build_native_filter_option_form_data(
+ _dashboard(json_metadata=None),
+ filter_config,
+ )
+
+ assert result is not None
+ assert result["time_range"] == "Last week"
+ assert result["granularity_sqla"] == "ds"
+
+
+def test_build_native_filter_option_form_data_falls_back_to_default_time_range() -> (
+ None
+):
+ result: dict[str, Any] | None = build_native_filter_option_form_data(
+ _dashboard(json_metadata=None),
+ _filter_config(),
+ )
+
+ assert result is not None
+ assert result["time_range"] == "No filter"
+
+
+def test_build_native_filter_option_form_data_missing_dataset_id() -> None:
+ result: dict[str, Any] | None = build_native_filter_option_form_data(
+ _dashboard(json_metadata=None),
+ _filter_config(targets=[{"column": {"name": "country"}}]),
+ )
+
+ assert result is None
+
+
+def test_build_native_filter_option_form_data_missing_column_name() -> None:
+ result: dict[str, Any] | None = build_native_filter_option_form_data(
+ _dashboard(json_metadata=None),
+ _filter_config(targets=[{"datasetId": 10, "column": {}}]),
+ )
+
+ assert result is None
+
+
+def test_build_native_filter_option_query_context_returns_query_context() -> None:
+ form_data: dict[str, Any] | None = build_native_filter_option_form_data(
+ _dashboard(json_metadata=None),
+ _filter_config(),
+ )
+ datasource: mock.MagicMock = mock.MagicMock()
+ datasource.cache_timeout = None
+ datasource.type = "table"
+ datasource.data = {"id": 10}
+ datasource.uid = "10__table"
+ datasource.column_names = ["country"]
+
+ expected: QueryContext = QueryContext(
+ datasource=datasource,
+ queries=[],
+ slice_=None,
+ form_data=form_data,
+ result_type=ChartDataResultType.FULL,
+ result_format=ChartDataResultFormat.JSON,
+ force=False,
+ custom_cache_timeout=None,
+ cache_values={},
+ )
+
+ with mock.patch(
+ "superset.tasks.native_filter_cache.ChartDataQueryContextSchema"
+ ) as schema_class:
+ schema_class.return_value.load.return_value = expected
+ result: QueryContext | None = build_native_filter_option_query_context(
+ form_data or {}
+ )
+
+ assert isinstance(result, QueryContext)
+ assert result == expected
+ schema_class.return_value.load.assert_called_once()
+
+
+def test_build_native_filter_option_query_context_groupby_in_payload() -> None:
+ form_data: dict[str, Any] | None = build_native_filter_option_form_data(
+ _dashboard(json_metadata=None),
+ {
+ **_filter_config(),
+ "adhoc_filters": [
+ {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ ],
+ },
+ )
+ expected: mock.MagicMock = mock.MagicMock(spec=QueryContext)
+
+ with (
+ mock.patch(
+ "superset.tasks.native_filter_cache.ChartDataQueryContextSchema"
+ ) as schema_class,
+ mock.patch(
+ "superset.tasks.native_filter_cache._resolve_datasource_engine",
+ return_value="postgresql",
+ ) as mock_resolve_engine,
+ ):
+ schema_class.return_value.load.return_value = expected
+ result: QueryContext | None = build_native_filter_option_query_context(
+ form_data or {}
+ )
+
+ mock_resolve_engine.assert_not_called()
+ assert result == expected
+ payload: dict[str, Any] = schema_class.return_value.load.call_args.args[0]
+ query: dict[str, Any] = payload["queries"][0]
+ assert query["groupby"] == ["country"]
+ assert "columns" not in query
+ assert query["filters"] == [{"col": "region", "op": "==", "val": "EMEA"}]
+ assert "adhoc_filters" not in query
+
+
+def test_build_native_filter_option_query_context_with_adhoc_filters() -> None:
+ adhoc_filters: list[dict[str, Any]] = [
+ {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "region",
+ "operator": "IN",
+ "comparator": ["EMEA", "APAC"],
+ }
+ ]
+ form_data: dict[str, Any] | None = build_native_filter_option_form_data(
+ _dashboard(json_metadata=None),
+ {
+ **_filter_config(),
+ "adhoc_filters": adhoc_filters,
+ },
+ )
+ expected: mock.MagicMock = mock.MagicMock(spec=QueryContext)
+
+ with (
+ mock.patch(
+ "superset.tasks.native_filter_cache.ChartDataQueryContextSchema"
+ ) as schema_class,
+ mock.patch(
+ "superset.tasks.native_filter_cache._resolve_datasource_engine",
+ return_value="postgresql",
+ ) as mock_resolve_engine,
+ ):
+ schema_class.return_value.load.return_value = expected
+ result: QueryContext | None = build_native_filter_option_query_context(
+ form_data or {}
+ )
+
+ mock_resolve_engine.assert_not_called()
+ assert result == expected
+ payload: dict[str, Any] = schema_class.return_value.load.call_args.args[0]
+ query: dict[str, Any] = payload["queries"][0]
+ assert query["filters"] == [{"col": "region", "op": "IN", "val": ["EMEA", "APAC"]}]
+ assert "adhoc_filters" not in query
+
+
+def test_resolve_datasource_engine_only_called_for_sql_filters() -> None:
+ simple_form_data: dict[str, Any] | None = build_native_filter_option_form_data(
+ _dashboard(json_metadata=None),
+ {
+ **_filter_config(),
+ "adhoc_filters": [
+ {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ ],
+ },
+ )
+ sql_form_data: dict[str, Any] | None = build_native_filter_option_form_data(
+ _dashboard(json_metadata=None),
+ {
+ **_filter_config(),
+ "adhoc_filters": [
+ {
+ "expressionType": "SQL",
+ "clause": "WHERE",
+ "sqlExpression": "region = 'EMEA'",
+ }
+ ],
+ },
+ )
+ expected: mock.MagicMock = mock.MagicMock(spec=QueryContext)
+
+ with (
+ mock.patch(
+ "superset.tasks.native_filter_cache.ChartDataQueryContextSchema"
+ ) as schema_class,
+ mock.patch(
+ "superset.tasks.native_filter_cache._resolve_datasource_engine",
+ return_value="postgresql",
+ ) as mock_resolve_engine,
+ ):
+ schema_class.return_value.load.return_value = expected
+
+ build_native_filter_option_query_context(simple_form_data or {})
+ mock_resolve_engine.assert_not_called()
+
+ build_native_filter_option_query_context(sql_form_data or {})
+ mock_resolve_engine.assert_called_once_with(10)
+
+
+def test_build_native_filter_option_query_context_missing_groupby() -> None:
+ result: QueryContext | None = build_native_filter_option_query_context(
+ {"datasource": "10__table"}
+ )
+
+ assert result is None
+
+
+def test_build_native_filter_option_query_context_malformed_groupby() -> None:
+ malformed_groupby_values: list[Any] = [
+ "country",
+ [{"label": "country"}],
+ [None],
+ [],
+ ]
+
+ for groupby in malformed_groupby_values:
+ result: QueryContext | None = build_native_filter_option_query_context(
+ {
+ "datasource": "10__table",
+ "groupby": groupby,
+ }
+ )
+
+ assert result is None