diff --git a/.changes/unreleased/Features-20231024-171020.yaml b/.changes/unreleased/Features-20231024-171020.yaml new file mode 100644 index 00000000..232a3b6d --- /dev/null +++ b/.changes/unreleased/Features-20231024-171020.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Support for date_part for Dimension & TimeDimension +time: 2023-10-24T17:10:20.59653-05:00 +custom: + Author: DevonFulcher + Issue: "188" diff --git a/dbt_semantic_interfaces/call_parameter_sets.py b/dbt_semantic_interfaces/call_parameter_sets.py index 9d827194..5a3b23ac 100644 --- a/dbt_semantic_interfaces/call_parameter_sets.py +++ b/dbt_semantic_interfaces/call_parameter_sets.py @@ -9,6 +9,7 @@ TimeDimensionReference, ) from dbt_semantic_interfaces.type_enums import TimeGranularity +from dbt_semantic_interfaces.type_enums.date_part import DatePart @dataclass(frozen=True) @@ -26,6 +27,7 @@ class TimeDimensionCallParameterSet: entity_path: Tuple[EntityReference, ...] time_dimension_reference: TimeDimensionReference time_granularity: Optional[TimeGranularity] = None + date_part: Optional[DatePart] = None @dataclass(frozen=True) diff --git a/dbt_semantic_interfaces/parsing/where_filter/parameter_set_factory.py b/dbt_semantic_interfaces/parsing/where_filter/parameter_set_factory.py index 12a2216f..af8fc085 100644 --- a/dbt_semantic_interfaces/parsing/where_filter/parameter_set_factory.py +++ b/dbt_semantic_interfaces/parsing/where_filter/parameter_set_factory.py @@ -17,6 +17,7 @@ TimeDimensionReference, ) from dbt_semantic_interfaces.type_enums import TimeGranularity +from dbt_semantic_interfaces.type_enums.date_part import DatePart class ParameterSetFactory: @@ -31,7 +32,10 @@ def _exception_message_for_incorrect_format(element_name: str) -> str: @staticmethod def create_time_dimension( - time_dimension_name: str, time_granularity_name: Optional[str] = None, entity_path: Sequence[str] = () + time_dimension_name: str, + time_granularity_name: Optional[str] = None, + entity_path: Sequence[str] = (), + date_part_name: Optional[str] = None, ) -> TimeDimensionCallParameterSet: """Gets called by Jinja when rendering {{ TimeDimension(...) }}.""" group_by_item_name = DunderedNameFormatter.parse_name(time_dimension_name) @@ -57,6 +61,7 @@ def create_time_dimension( tuple(EntityReference(element_name=arg) for arg in entity_path) + group_by_item_name.entity_links ), time_granularity=TimeGranularity(time_granularity_name) if time_granularity_name is not None else None, + date_part=DatePart(date_part_name.lower()) if date_part_name else None, ) @staticmethod diff --git a/dbt_semantic_interfaces/parsing/where_filter/where_filter_dimension.py b/dbt_semantic_interfaces/parsing/where_filter/where_filter_dimension.py index fbd884d4..b03a7eab 100644 --- a/dbt_semantic_interfaces/parsing/where_filter/where_filter_dimension.py +++ b/dbt_semantic_interfaces/parsing/where_filter/where_filter_dimension.py @@ -27,6 +27,7 @@ def __init__( # noqa self.name = name self.entity_path = entity_path self.time_granularity_name: Optional[str] = None + self.date_part_name: Optional[str] = None def grain(self, time_granularity: str) -> QueryInterfaceDimension: """The time granularity.""" @@ -37,9 +38,10 @@ def descending(self, _is_descending: bool) -> QueryInterfaceDimension: """Set the sort order for order-by.""" raise InvalidQuerySyntax("descending is invalid in the where parameter and filter spec") - def date_part(self, _date_part: str) -> QueryInterfaceDimension: + def date_part(self, date_part_name: str) -> QueryInterfaceDimension: """Date part to extract from the dimension.""" - raise InvalidQuerySyntax("date_part isn't currently supported in the where parameter and filter spec") + self.date_part_name = date_part_name + return self class WhereFilterDimensionFactory(ProtocolHint[QueryInterfaceDimensionFactory]): diff --git a/dbt_semantic_interfaces/parsing/where_filter/where_filter_parser.py b/dbt_semantic_interfaces/parsing/where_filter/where_filter_parser.py index 0303572a..083612ce 100644 --- a/dbt_semantic_interfaces/parsing/where_filter/where_filter_parser.py +++ b/dbt_semantic_interfaces/parsing/where_filter/where_filter_parser.py @@ -43,17 +43,15 @@ def parse_call_parameter_sets(where_sql_template: str) -> FilterCallParameterSet raise ParseWhereFilterException(f"Error while parsing Jinja template:\n{where_sql_template}") from e """ - Dimensions that are created with a grain parameter, Dimension(...).grain(...), are + Dimensions that are created with a grain or date_part parameter, for instance Dimension(...).grain(...), are added to time_dimension_call_parameter_sets otherwise they are add to dimension_call_parameter_sets """ dimension_call_parameter_sets = [] for dimension in dimension_factory.created: - if dimension.time_granularity_name: + if dimension.time_granularity_name or dimension.date_part_name: time_dimension_factory.time_dimension_call_parameter_sets.append( ParameterSetFactory.create_time_dimension( - dimension.name, - dimension.time_granularity_name, - dimension.entity_path, + dimension.name, dimension.time_granularity_name, dimension.entity_path, dimension.date_part_name ) ) else: diff --git a/dbt_semantic_interfaces/parsing/where_filter/where_filter_time_dimension.py b/dbt_semantic_interfaces/parsing/where_filter/where_filter_time_dimension.py index 420e286a..693c8344 100644 --- a/dbt_semantic_interfaces/parsing/where_filter/where_filter_time_dimension.py +++ b/dbt_semantic_interfaces/parsing/where_filter/where_filter_time_dimension.py @@ -49,9 +49,9 @@ def create( """Gets called by Jinja when rendering {{ TimeDimension(...) }}.""" if descending is not None: raise InvalidQuerySyntax("descending is invalid in the where parameter and filter spec") - if date_part_name is not None: - raise InvalidQuerySyntax("date_part isn't currently supported in the where parameter and filter spec") self.time_dimension_call_parameter_sets.append( - ParameterSetFactory.create_time_dimension(time_dimension_name, time_granularity_name, entity_path) + ParameterSetFactory.create_time_dimension( + time_dimension_name, time_granularity_name, entity_path, date_part_name + ) ) return TimeDimensionStub() diff --git a/dbt_semantic_interfaces/type_enums/date_part.py b/dbt_semantic_interfaces/type_enums/date_part.py new file mode 100644 index 00000000..b6c9dcba --- /dev/null +++ b/dbt_semantic_interfaces/type_enums/date_part.py @@ -0,0 +1,45 @@ +from enum import Enum +from typing import List + +from dbt_semantic_interfaces.enum_extension import assert_values_exhausted +from dbt_semantic_interfaces.type_enums.time_granularity import TimeGranularity + + +class DatePart(Enum): + """Date parts able to be extracted from a time dimension. + + Week is not an option due to divergent results across engine contexts see: https://github.com/dbt-labs/metricflow/pull/812 + + TODO: add support for hour, minute, second once those granularities are available + """ + + YEAR = "year" + QUARTER = "quarter" + MONTH = "month" + DAY = "day" + DOW = "dow" + DOY = "doy" + + def to_int(self) -> int: + """Convert to an int so that the size of the granularity can be easily compared.""" + if self is DatePart.DAY: + return TimeGranularity.DAY.to_int() + elif self is DatePart.DOW: + return TimeGranularity.DAY.to_int() + elif self is DatePart.DOY: + return TimeGranularity.DAY.to_int() + elif self is DatePart.WEEK: + return TimeGranularity.WEEK.to_int() + elif self is DatePart.MONTH: + return TimeGranularity.MONTH.to_int() + elif self is DatePart.QUARTER: + return TimeGranularity.QUARTER.to_int() + elif self is DatePart.YEAR: + return TimeGranularity.YEAR.to_int() + else: + assert_values_exhausted(self) + + @property + def compatible_granularities(self) -> List[TimeGranularity]: + """Granularities that can be queried with this date part.""" + return [granularity for granularity in TimeGranularity if granularity.to_int() >= self.to_int()] diff --git a/tests/parsing/test_where_filter_parsing.py b/tests/parsing/test_where_filter_parsing.py index 11eed4d3..cf741f32 100644 --- a/tests/parsing/test_where_filter_parsing.py +++ b/tests/parsing/test_where_filter_parsing.py @@ -11,11 +11,17 @@ """ +import pytest + from dbt_semantic_interfaces.implementations.base import HashableBaseModel from dbt_semantic_interfaces.implementations.filters.where_filter import ( PydanticWhereFilter, PydanticWhereFilterIntersection, ) +from dbt_semantic_interfaces.parsing.where_filter.where_filter_parser import ( + WhereFilterParser, +) +from dbt_semantic_interfaces.type_enums.date_part import DatePart __BOOLEAN_EXPRESSION__ = "1 > 0" @@ -135,3 +141,24 @@ def test_where_filter_intersection_from_partially_deserialized_list_of_strings() parsed_model = ModelWithWhereFilterIntersection.parse_obj(obj) assert parsed_model.where_filter == expected_parsed_output + + +@pytest.mark.parametrize( + "where", + [ + "{{ TimeDimension('metric_time', 'YEAR', [], None, 'YEAR') }} > '2023-01-01'", + "{{ TimeDimension(time_dimension_name='metric_time', time_granularity_name='YEAR', date_part_name='YEAR') }}" + + "> '2023-01-01'", + ], +) +def test_time_dimension_date_part(where: str) -> None: # noqa + param_sets = WhereFilterParser.parse_call_parameter_sets(where) + assert len(param_sets.time_dimension_call_parameter_sets) == 1 + assert param_sets.time_dimension_call_parameter_sets[0].date_part == DatePart.YEAR + + +def test_dimension_date_part() -> None: # noqa + where = "{{ Dimension('metric_time').grain('DAY').date_part('YEAR') }} > '2023-01-01'" + param_sets = WhereFilterParser.parse_call_parameter_sets(where) + assert len(param_sets.time_dimension_call_parameter_sets) == 1 + assert param_sets.time_dimension_call_parameter_sets[0].date_part == DatePart.YEAR