diff --git a/docs/docs/configuration/sql-templating.mdx b/docs/docs/configuration/sql-templating.mdx index 0e618fd9c19..7f094f1d87a 100644 --- a/docs/docs/configuration/sql-templating.mdx +++ b/docs/docs/configuration/sql-templating.mdx @@ -461,3 +461,37 @@ This macro avoids copy/paste, allowing users to centralize the metric definition The `dataset_id` parameter is optional, and if not provided Superset will use the current dataset from context (for example, when using this macro in the Chart Builder, by default the `macro_key` will be searched in the dataset powering the chart). The parameter can be used in SQL Lab, or when fetching a metric from another dataset. + +## Available Filters + +Superset supports [builtin filters from the Jinja2 templating package](https://jinja.palletsprojects.com/en/stable/templates/#builtin-filters). Custom filters have also been implemented: + +**Where In** +Parses a list into a SQL-compatible statement. This is useful with macros that return an array (for example the `filter_values` macro): + +``` +Dashboard filter with "First", "Second" and "Third" options selected +{{ filter_values('column') }} => ["First", "Second", "Third"] +{{ filter_values('column')|where_in }} => ('First', 'Second', 'Third') +``` + +By default, this filter returns `()` (as a string) in case the value is null. The `default_to_none` parameter can be se to `True` to return null in this case: + +``` +Dashboard filter without any value applied +{{ filter_values('column') }} => () +{{ filter_values('column')|where_in(default_to_none=True) }} => None +``` + +**To Datetime** + +Loads a string as a `datetime` object. This is useful when performing date operations. For example: +``` +{% set from_expr = get_time_filter("dttm", strftime="%Y-%m-%d").from_expr %} +{% set to_expr = get_time_filter("dttm", strftime="%Y-%m-%d").to_expr %} +{% if (to_expr|to_datetime(format="%Y-%m-%d") - from_expr|to_datetime(format="%Y-%m-%d")).days > 100 %} + do something +{% else %} + do something else +{% endif %} +``` diff --git a/superset/jinja_context.py b/superset/jinja_context.py index a4d8e6d6a49..0f56886dd2e 100644 --- a/superset/jinja_context.py +++ b/superset/jinja_context.py @@ -561,6 +561,24 @@ class WhereInMacro: # pylint: disable=too-few-public-methods return result +def to_datetime( + value: str | None, format: str = "%Y-%m-%d %H:%M:%S" +) -> datetime | None: + """ + Parses a string into a datetime object. + + :param value: the string to parse. + :param format: the format to parse the string with. + :returns: the parsed datetime object. + """ + if not value: + return None + + # This value might come from a macro that could be including wrapping quotes + value = value.strip("'\"") + return datetime.strptime(value, format) + + class BaseTemplateProcessor: """ Base class for database-specific jinja context @@ -596,6 +614,7 @@ class BaseTemplateProcessor: # custom filters self.env.filters["where_in"] = WhereInMacro(database.get_dialect()) + self.env.filters["to_datetime"] = to_datetime def set_context(self, **kwargs: Any) -> None: self._context.update(kwargs) diff --git a/tests/unit_tests/jinja_context_test.py b/tests/unit_tests/jinja_context_test.py index be6e5fb55e3..5cc6f218a89 100644 --- a/tests/unit_tests/jinja_context_test.py +++ b/tests/unit_tests/jinja_context_test.py @@ -17,6 +17,7 @@ # pylint: disable=invalid-name, unused-argument from __future__ import annotations +from datetime import datetime from typing import Any import pytest @@ -38,6 +39,7 @@ from superset.jinja_context import ( metric_macro, safe_proxy, TimeFilter, + to_datetime, WhereInMacro, ) from superset.models.core import Database @@ -429,6 +431,59 @@ def test_where_in_empty_list() -> None: assert where_in([], default_to_none=True) is None +@pytest.mark.parametrize( + "value,format,output", + [ + ("2025-03-20 15:55:00", None, datetime(2025, 3, 20, 15, 55)), + (None, None, None), + ("2025-03-20", "%Y-%m-%d", datetime(2025, 3, 20)), + ("'2025-03-20'", "%Y-%m-%d", datetime(2025, 3, 20)), + ], +) +def test_to_datetime( + value: str | None, format: str | None, output: datetime | None +) -> None: + """ + Test the ``to_datetime`` custom filter. + """ + + result = ( + to_datetime(value, format=format) if format is not None else to_datetime(value) + ) + assert result == output + + +@pytest.mark.parametrize( + "value,format,match", + [ + ( + "2025-03-20", + None, + "time data '2025-03-20' does not match format '%Y-%m-%d %H:%M:%S'", + ), + ( + "2025-03-20 15:55:00", + "%Y-%m-%d", + "unconverted data remains: 15:55:00", + ), + ], +) +def test_to_datetime_raises(value: str, format: str | None, match: str) -> None: + """ + Test the ``to_datetime`` custom filter raises with an incorrect + format. + """ + with pytest.raises( + ValueError, + match=match, + ): + ( + to_datetime(value, format=format) + if format is not None + else to_datetime(value) + ) + + def test_dataset_macro(mocker: MockerFixture) -> None: """ Test the ``dataset_macro`` macro.