From 15de192124d1812d492406376353071e4a2143e7 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Wed, 28 Oct 2026 17:13:18 +0100 Subject: [PATCH 1/3] fix(core): return 400 instead of 500 on malformed time ranges in temporal filters (#45459) When a temporal filter carries an unparseable time range (e.g. 'a : b' or inverted date boundaries), get_since_until_from_time_range raises TimeRangeParseFailError or ValueError uncaught inside get_sqla_query and processing_time_offsets. This bypassed query validation handlers and surfaced as HTTP 500. This patch: - Catches TimeRangeParseFailError, TimeRangeAmbiguousError, ValidationError, and ValueError across get_sqla_query and processing_time_offsets. - Converts the failure to QueryObjectValidationError naming the invalid column. - Adds comprehensive unit tests in tests/unit_tests/models/helpers_test.py. Fixes #45459 Signed-off-by: Francesco Castaldi --- superset/models/helpers.py | 85 ++++++++++++++++---- tests/unit_tests/models/helpers_test.py | 101 ++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 14 deletions(-) diff --git a/superset/models/helpers.py b/superset/models/helpers.py index cd8c27edee0e..8838a8b4448e 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -61,6 +61,7 @@ from flask_babel import get_locale, lazy_gettext as _ from jinja2.exceptions import TemplateError, UndefinedError from markupsafe import escape, Markup +from marshmallow import ValidationError from pandas import DateOffset, Timedelta from sqlalchemy import and_, Column, or_, UniqueConstraint from sqlalchemy.exc import MultipleResultsFound @@ -88,6 +89,10 @@ from superset import db, is_feature_enabled from superset.advanced_data_type.types import AdvancedDataTypeResponse +from superset.commands.chart.exceptions import ( + TimeRangeAmbiguousError, + TimeRangeParseFailError, +) from superset.common.db_query_status import QueryStatus from superset.common.grouping_sets import ( grouping_marker_label, @@ -3037,7 +3042,18 @@ def processing_time_offsets( # pylint: disable=too-many-locals,too-many-stateme cache_keys: list[str | None] = [] offset_dfs: dict[str, pd.DataFrame] = {} - outer_from_dttm, outer_to_dttm = get_since_until_from_query_object(query_object) + try: + outer_from_dttm, outer_to_dttm = get_since_until_from_query_object( + query_object + ) + except ( + TimeRangeParseFailError, + TimeRangeAmbiguousError, + ValidationError, + ValueError, + ) as ex: + raise QueryObjectValidationError(str(ex)) from ex + if not outer_from_dttm or not outer_to_dttm: raise QueryObjectValidationError( _( @@ -3065,7 +3081,12 @@ def processing_time_offsets( # pylint: disable=too-many-locals,too-many-stateme offset_from_dttm, offset_to_dttm = ( get_since_until_from_time_range(time_range=offset) ) - except ValueError as ex: + except ( + TimeRangeParseFailError, + TimeRangeAmbiguousError, + ValidationError, + ValueError, + ) as ex: raise QueryObjectValidationError(str(ex)) from ex # Use the specified range directly @@ -3189,13 +3210,29 @@ def processing_time_offsets( # pylint: disable=too-many-locals,too-many-stateme flt.get("val"), str ): time_range = cast(str, flt.get("val")) - ( - new_outer_from_dttm, - new_outer_to_dttm, - ) = get_since_until_from_time_range( - time_range=time_range, - time_shift=offset, - ) + try: + ( + new_outer_from_dttm, + new_outer_to_dttm, + ) = get_since_until_from_time_range( + time_range=time_range, + time_shift=offset, + ) + except ( + TimeRangeParseFailError, + TimeRangeAmbiguousError, + ValidationError, + ValueError, + ) as ex: + col_name = flt.get("col") or "unknown" + raise QueryObjectValidationError( + _( + "Cannot parse time range for filter " + "'%(col)s': %(err)s", + col=col_name, + err=str(ex), + ) + ) from ex flt["val"] = f"{new_outer_from_dttm} : {new_outer_to_dttm}" else: # If it IS a datetime series, we still need to clear conflicts @@ -6980,11 +7017,31 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma and isinstance(eq, str) and col_obj is not None ): - _since, _until = get_since_until_from_time_range( - time_range=eq, - time_shift=time_shift, - extras=extras, - ) + try: + _since, _until = get_since_until_from_time_range( + time_range=eq, + time_shift=time_shift, + extras=extras, + ) + except ( + TimeRangeParseFailError, + TimeRangeAmbiguousError, + ValidationError, + ValueError, + ) as ex: + col_name = ( + col_obj.column_name + if hasattr(col_obj, "column_name") + else str(col_obj) + ) + raise QueryObjectValidationError( + _( + "Cannot parse time range for filter " + "'%(col)s': %(err)s", + col=col_name, + err=str(ex), + ) + ) from ex _temporal_filter = self.get_time_filter( time_col=col_obj, start_dttm=_since, diff --git a/tests/unit_tests/models/helpers_test.py b/tests/unit_tests/models/helpers_test.py index 7a2f39fceacd..11753c2e8963 100644 --- a/tests/unit_tests/models/helpers_test.py +++ b/tests/unit_tests/models/helpers_test.py @@ -6821,3 +6821,104 @@ def compile_not_in() -> str: kql = compile_not_in() assert "isnull(state)" in kql assert "IS NULL" not in kql + + +def test_get_sqla_query_malformed_temporal_range_raises_validation_error( + database: Database, +) -> None: + """ + Test that an invalid temporal range filter (e.g. 'a : b' or inverted dates) + raises QueryObjectValidationError naming the column, returning 400 instead of 500. + Fixes #45459. + """ + from superset.connectors.sqla.models import SqlaTable, TableColumn + from superset.exceptions import QueryObjectValidationError + + table = SqlaTable( + database=database, + schema=None, + table_name="t", + columns=[ + TableColumn(column_name="ds", type="TIMESTAMP", is_dttm=True), + TableColumn(column_name="other_ds", type="TIMESTAMP", is_dttm=True), + ], + ) + + with pytest.raises(QueryObjectValidationError, match="other_ds"): + table.get_sqla_query( + columns=["ds"], + metrics=[], + extras={}, + filter=[ + { + "col": "ds", + "op": FilterOperator.TEMPORAL_RANGE, + "val": "2024-01-01 : 2024-01-02", + }, + { + "col": "other_ds", + "op": FilterOperator.TEMPORAL_RANGE, + "val": "a : b", + }, + ], + granularity=None, + is_timeseries=False, + ) + + with pytest.raises(QueryObjectValidationError, match="other_ds"): + table.get_sqla_query( + columns=["ds"], + metrics=[], + extras={}, + filter=[ + { + "col": "ds", + "op": FilterOperator.TEMPORAL_RANGE, + "val": "2024-01-01 : 2024-01-02", + }, + { + "col": "other_ds", + "op": FilterOperator.TEMPORAL_RANGE, + "val": "2024-02-01 : 2024-01-01", + }, + ], + granularity=None, + is_timeseries=False, + ) + + +def test_processing_time_offsets_malformed_time_range_raises_validation_error( + database: Database, +) -> None: + """ + Test that processing_time_offsets raises QueryObjectValidationError on + malformed temporal filters when calculating time shifts. + Fixes #45459. + """ + import pandas as pd + + from superset.common.query_object import QueryObject + from superset.connectors.sqla.models import SqlaTable, TableColumn + from superset.exceptions import QueryObjectValidationError + + table = SqlaTable( + database=database, + schema=None, + table_name="t", + columns=[ + TableColumn(column_name="ds", type="TIMESTAMP", is_dttm=True), + ], + ) + + query_object = QueryObject( + columns=["ds"], + filter=[ + {"col": "ds", "op": FilterOperator.TEMPORAL_RANGE, "val": "a : b"}, + ], + time_offsets=["1 day ago"], + ) + + df = pd.DataFrame({"ds": [pd.Timestamp("2024-01-01")]}) + + with pytest.raises(QueryObjectValidationError): + table.processing_time_offsets(df, query_object) From e74e08b55cd2f25b34e0671d9d12083279785e65 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Thu, 29 Oct 2026 22:46:20 +0100 Subject: [PATCH 2/3] fix(core): address review feedback from @sadpandajoe on temporal filters and pot drift (#45822) Signed-off-by: Francesco Castaldi --- superset/models/helpers.py | 16 +-- tests/unit_tests/models/helpers_test.py | 131 ++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 19 deletions(-) diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 8838a8b4448e..c4a5f2b80e46 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -3226,12 +3226,8 @@ def processing_time_offsets( # pylint: disable=too-many-locals,too-many-stateme ) as ex: col_name = flt.get("col") or "unknown" raise QueryObjectValidationError( - _( - "Cannot parse time range for filter " - "'%(col)s': %(err)s", - col=col_name, - err=str(ex), - ) + f"Cannot parse time range for filter " + f"'{col_name}': {ex}" ) from ex flt["val"] = f"{new_outer_from_dttm} : {new_outer_to_dttm}" else: @@ -7035,12 +7031,8 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma else str(col_obj) ) raise QueryObjectValidationError( - _( - "Cannot parse time range for filter " - "'%(col)s': %(err)s", - col=col_name, - err=str(ex), - ) + f"Cannot parse time range for filter " + f"'{col_name}': {ex}" ) from ex _temporal_filter = self.get_time_filter( time_col=col_obj, diff --git a/tests/unit_tests/models/helpers_test.py b/tests/unit_tests/models/helpers_test.py index 11753c2e8963..08bf5f1ec39a 100644 --- a/tests/unit_tests/models/helpers_test.py +++ b/tests/unit_tests/models/helpers_test.py @@ -6831,6 +6831,7 @@ def test_get_sqla_query_malformed_temporal_range_raises_validation_error( raises QueryObjectValidationError naming the column, returning 400 instead of 500. Fixes #45459. """ + from superset.commands.chart.exceptions import TimeRangeParseFailError from superset.connectors.sqla.models import SqlaTable, TableColumn from superset.exceptions import QueryObjectValidationError @@ -6844,7 +6845,7 @@ def test_get_sqla_query_malformed_temporal_range_raises_validation_error( ], ) - with pytest.raises(QueryObjectValidationError, match="other_ds"): + with pytest.raises(QueryObjectValidationError, match="other_ds") as exc_info: table.get_sqla_query( columns=["ds"], metrics=[], @@ -6864,8 +6865,9 @@ def test_get_sqla_query_malformed_temporal_range_raises_validation_error( granularity=None, is_timeseries=False, ) + assert isinstance(exc_info.value.__cause__, (TimeRangeParseFailError, ValueError)) - with pytest.raises(QueryObjectValidationError, match="other_ds"): + with pytest.raises(QueryObjectValidationError, match="other_ds") as exc_info: table.get_sqla_query( columns=["ds"], metrics=[], @@ -6885,22 +6887,83 @@ def test_get_sqla_query_malformed_temporal_range_raises_validation_error( granularity=None, is_timeseries=False, ) + assert isinstance(exc_info.value.__cause__, (TimeRangeParseFailError, ValueError)) -def test_processing_time_offsets_malformed_time_range_raises_validation_error( +def test_processing_time_offsets_malformed_relative_filter_raises_validation_error( database: Database, ) -> None: """ - Test that processing_time_offsets raises QueryObjectValidationError on - malformed temporal filters when calculating time shifts. + Test that processing_time_offsets raises QueryObjectValidationError naming + the column and chaining TimeRangeParseFailError when rewriting temporal filters + for relative time shifts. Fixes #45459. """ import pandas as pd + from superset.commands.chart.exceptions import TimeRangeParseFailError from superset.common.query_object import QueryObject from superset.connectors.sqla.models import SqlaTable, TableColumn from superset.exceptions import QueryObjectValidationError + table = SqlaTable( + database=database, + schema=None, + table_name="t", + columns=[ + TableColumn(column_name="category", type="VARCHAR(255)"), + TableColumn(column_name="ds", type="TIMESTAMP", is_dttm=True), + TableColumn(column_name="other_ds", type="TIMESTAMP", is_dttm=True), + ], + ) + + query_object = QueryObject( + columns=["category"], + time_range="2024-01-01 : 2024-01-02", + filters=[ + { + "col": "ds", + "op": FilterOperator.TEMPORAL_RANGE, + "val": "2024-01-01 : 2024-01-02", + }, + { + "col": "other_ds", + "op": FilterOperator.TEMPORAL_RANGE, + "val": "a : b", + }, + ], + time_offsets=["1 day ago"], + ) + + df = pd.DataFrame({"category": ["A", "B"]}) + + with pytest.raises(QueryObjectValidationError, match="other_ds") as exc_info: + table.processing_time_offsets(df, query_object) + + assert isinstance(exc_info.value.__cause__, TimeRangeParseFailError) + + +def test_processing_time_offsets_malformed_date_range_offset_raises_validation_error( + database: Database, + mocker: MockerFixture, +) -> None: + """ + Test that processing_time_offsets raises QueryObjectValidationError when + DATE_RANGE_TIMESHIFTS_ENABLED is enabled and a date range offset is invalid. + Fixes #45459. + """ + import pandas as pd + + from superset.commands.chart.exceptions import TimeRangeParseFailError + from superset.common.query_object import QueryObject + from superset.connectors.sqla.models import SqlaTable, TableColumn + from superset.exceptions import QueryObjectValidationError + + mocker.patch( + "superset.models.helpers.feature_flag_manager.is_feature_enabled", + side_effect=lambda feature: feature == "DATE_RANGE_TIMESHIFTS_ENABLED", + ) + table = SqlaTable( database=database, schema=None, @@ -6912,7 +6975,56 @@ def test_processing_time_offsets_malformed_time_range_raises_validation_error( query_object = QueryObject( columns=["ds"], - filter=[ + time_range="2024-01-01 : 2024-01-02", + filters=[ + { + "col": "ds", + "op": FilterOperator.TEMPORAL_RANGE, + "val": "2024-01-01 : 2024-01-02", + }, + ], + time_offsets=["2024-02-01 : 2024-01-01"], + ) + + df = pd.DataFrame({"ds": [pd.Timestamp("2024-01-01")]}) + + with pytest.raises(QueryObjectValidationError) as exc_info: + table.processing_time_offsets(df, query_object) + + assert isinstance( + exc_info.value.__cause__, + (TimeRangeParseFailError, ValueError), + ) + + +def test_processing_time_offsets_malformed_outer_time_range_raises_validation_error( + database: Database, +) -> None: + """ + Test that processing_time_offsets raises QueryObjectValidationError when + the outer time range itself is malformed. + Fixes #45459. + """ + import pandas as pd + + from superset.commands.chart.exceptions import TimeRangeParseFailError + from superset.common.query_object import QueryObject + from superset.connectors.sqla.models import SqlaTable, TableColumn + from superset.exceptions import QueryObjectValidationError + + table = SqlaTable( + database=database, + schema=None, + table_name="t", + columns=[ + TableColumn(column_name="ds", type="TIMESTAMP", is_dttm=True), + ], + ) + + query_object = QueryObject( + columns=["ds"], + time_range="a : b", + filters=[ {"col": "ds", "op": FilterOperator.TEMPORAL_RANGE, "val": "a : b"}, ], time_offsets=["1 day ago"], @@ -6920,5 +7032,10 @@ def test_processing_time_offsets_malformed_time_range_raises_validation_error( df = pd.DataFrame({"ds": [pd.Timestamp("2024-01-01")]}) - with pytest.raises(QueryObjectValidationError): + with pytest.raises(QueryObjectValidationError) as exc_info: table.processing_time_offsets(df, query_object) + + assert isinstance( + exc_info.value.__cause__, + (TimeRangeParseFailError, ValueError), + ) From 089c8d37a43fddb70f28a49599b0b4a7ba2d37cd Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Fri, 30 Oct 2026 10:16:25 +0100 Subject: [PATCH 3/3] fix(core): catch TypeError on mixed-timezone temporal ranges and format helpers.py (#45822) Signed-off-by: Francesco Castaldi --- superset/models/helpers.py | 6 ++++-- tests/unit_tests/models/helpers_test.py | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/superset/models/helpers.py b/superset/models/helpers.py index c4a5f2b80e46..61884ed6c008 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -3086,6 +3086,7 @@ def processing_time_offsets( # pylint: disable=too-many-locals,too-many-stateme TimeRangeAmbiguousError, ValidationError, ValueError, + TypeError, ) as ex: raise QueryObjectValidationError(str(ex)) from ex @@ -3223,6 +3224,7 @@ def processing_time_offsets( # pylint: disable=too-many-locals,too-many-stateme TimeRangeAmbiguousError, ValidationError, ValueError, + TypeError, ) as ex: col_name = flt.get("col") or "unknown" raise QueryObjectValidationError( @@ -7024,6 +7026,7 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma TimeRangeAmbiguousError, ValidationError, ValueError, + TypeError, ) as ex: col_name = ( col_obj.column_name @@ -7031,8 +7034,7 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma else str(col_obj) ) raise QueryObjectValidationError( - f"Cannot parse time range for filter " - f"'{col_name}': {ex}" + f"Cannot parse time range for filter '{col_name}': {ex}" ) from ex _temporal_filter = self.get_time_filter( time_col=col_obj, diff --git a/tests/unit_tests/models/helpers_test.py b/tests/unit_tests/models/helpers_test.py index 08bf5f1ec39a..6b07bf54a9de 100644 --- a/tests/unit_tests/models/helpers_test.py +++ b/tests/unit_tests/models/helpers_test.py @@ -6889,6 +6889,31 @@ def test_get_sqla_query_malformed_temporal_range_raises_validation_error( ) assert isinstance(exc_info.value.__cause__, (TimeRangeParseFailError, ValueError)) + with pytest.raises(QueryObjectValidationError, match="other_ds") as exc_info: + table.get_sqla_query( + columns=["ds"], + metrics=[], + extras={}, + filter=[ + { + "col": "ds", + "op": FilterOperator.TEMPORAL_RANGE, + "val": "2024-01-01 : 2024-01-02", + }, + { + "col": "other_ds", + "op": FilterOperator.TEMPORAL_RANGE, + "val": "2024-01-01T00:00:00Z : 2024-01-02", + }, + ], + granularity=None, + is_timeseries=False, + ) + assert isinstance( + exc_info.value.__cause__, + (TimeRangeParseFailError, ValueError, TypeError), + ) + def test_processing_time_offsets_malformed_relative_filter_raises_validation_error( database: Database,