From f9149eba75d59eefeb54d699b2425a893a86036f Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Tue, 30 Jun 2026 16:15:54 -0400 Subject: [PATCH] fix(semantic layers): start/end ranges --- superset/semantic_layers/mapper.py | 11 +-- .../unit_tests/semantic_layers/mapper_test.py | 72 +++++++++++++++++-- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/superset/semantic_layers/mapper.py b/superset/semantic_layers/mapper.py index e40629c3ce2..cfea0ab274f 100644 --- a/superset/semantic_layers/mapper.py +++ b/superset/semantic_layers/mapper.py @@ -52,7 +52,10 @@ from superset_core.semantic_layers.view import SemanticViewFeature from superset.common.db_query_status import QueryStatus from superset.common.query_object import QueryObject -from superset.common.utils.time_range_utils import get_since_until_from_query_object +from superset.common.utils.time_range_utils import ( + get_since_until_from_query_object, + get_since_until_from_time_range, +) from superset.connectors.sqla.models import BaseDatasource from superset.constants import NO_TIME_RANGE from superset.models.helpers import QueryResult @@ -543,9 +546,9 @@ def _convert_query_object_filter( if operator_str == FilterOperator.TEMPORAL_RANGE.value: if not isinstance(value, str) or value == NO_TIME_RANGE: return None - start, end = (side.strip() for side in value.split(" : ")) + start, end = get_since_until_from_time_range(time_range=value) filters: set[Filter] = set() - if start: + if start is not None: filters.add( Filter( type=PredicateType.WHERE, @@ -554,7 +557,7 @@ def _convert_query_object_filter( value=_coerce_scalar_filter_value(start, dimension), ) ) - if end: + if end is not None: filters.add( Filter( type=PredicateType.WHERE, diff --git a/tests/unit_tests/semantic_layers/mapper_test.py b/tests/unit_tests/semantic_layers/mapper_test.py index 0cbb59c8430..f7ec35cd4d4 100644 --- a/tests/unit_tests/semantic_layers/mapper_test.py +++ b/tests/unit_tests/semantic_layers/mapper_test.py @@ -20,6 +20,7 @@ from typing import Any from unittest.mock import MagicMock from zoneinfo import ZoneInfo +import freezegun import pandas as pd import pyarrow as pa import pytest @@ -1980,11 +1981,11 @@ def test_get_time_bounds_with_offset_no_bounds( def test_convert_query_object_filter_temporal_range_with_value() -> None: """ - Test conversion of TEMPORAL_RANGE filter with valid string value. + Test conversion of TEMPORAL_RANGE filter with an explicit "start : end" value. """ all_dimensions = { "order_date": Dimension( - "order_date", "order_date", pa.utf8(), "order_date", "Order date" + "order_date", "order_date", pa.timestamp("us"), "order_date", "Order date" ) } filter_: ValidatedQueryObjectFilterClause = { @@ -2000,17 +2001,80 @@ def test_convert_query_object_filter_temporal_range_with_value() -> None: type=PredicateType.WHERE, column=all_dimensions["order_date"], operator=Operator.GREATER_THAN_OR_EQUAL, - value="2025-01-01", + value=datetime(2025, 1, 1), ), Filter( type=PredicateType.WHERE, column=all_dimensions["order_date"], operator=Operator.LESS_THAN, - value="2025-12-31", + value=datetime(2025, 12, 31), ), } +@pytest.mark.parametrize( + "time_range", + [ + "Last week", + "Last 7 days", + "Last month", + "Next 30 days", + "previous calendar week", + "previous calendar month", + "previous calendar year", + "Current day", + "Current week", + ], +) +def test_convert_query_object_filter_temporal_range_named_ranges( + time_range: str, +) -> None: + """ + Named time ranges must be parsed via the standard time-range parser rather than + split on " : ". Previously these raised ``ValueError`` from + ``"Last week".split(" : ")``. + """ + all_dimensions = { + "order_date": Dimension( + "order_date", "order_date", pa.timestamp("us"), "order_date", "Order date" + ) + } + filter_: ValidatedQueryObjectFilterClause = { + "op": FilterOperator.TEMPORAL_RANGE.value, + "col": "order_date", + "val": time_range, + } + + with freezegun.freeze_time("2025-10-15"): + result = _convert_query_object_filter(filter_, all_dimensions) + + assert result is not None + assert {f.operator for f in result} == { + Operator.GREATER_THAN_OR_EQUAL, + Operator.LESS_THAN, + } + for f in result: + assert isinstance(f.value, datetime) + + +def test_convert_query_object_filter_temporal_range_no_filter() -> None: + """ + A "No filter" value should produce no filters at all. + """ + all_dimensions = { + "order_date": Dimension( + "order_date", "order_date", pa.timestamp("us"), "order_date", "Order date" + ) + } + filter_: ValidatedQueryObjectFilterClause = { + "op": FilterOperator.TEMPORAL_RANGE.value, + "col": "order_date", + "val": "No filter", + } + + assert _convert_query_object_filter(filter_, all_dimensions) is None + + def test_convert_query_object_filter_temporal_range_coerces_date_bounds() -> None: """ TEMPORAL_RANGE bounds should be coerced against the dimension's dtype so