From 68edcfb8c67bcb371b83411ab8f0be365e22fc7e Mon Sep 17 00:00:00 2001 From: Courtney Holcomb Date: Thu, 21 Sep 2023 14:07:28 -0700 Subject: [PATCH] Specific implementations for query parameters --- metricflow/engine/metricflow_engine.py | 33 +++++---- metricflow/protocols/query_parameter.py | 40 +++++++---- metricflow/query/query_parser.py | 70 ++++++++++--------- .../specs/query_param_implementations.py | 39 ++++++++++- metricflow/test/conftest.py | 16 ----- .../test/integration/test_configured_cases.py | 18 +++-- metricflow/test/query/test_query_parser.py | 36 ++++++---- 7 files changed, 152 insertions(+), 100 deletions(-) diff --git a/metricflow/engine/metricflow_engine.py b/metricflow/engine/metricflow_engine.py index 21f7bc899b..0ae5976325 100644 --- a/metricflow/engine/metricflow_engine.py +++ b/metricflow/engine/metricflow_engine.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum -from typing import List, Optional, Sequence, Tuple +from typing import List, Optional, Sequence, Tuple, Union import pandas as pd from dbt_semantic_interfaces.implementations.elements.dimension import PydanticDimensionTypeParams @@ -47,7 +47,12 @@ DataflowToExecutionPlanConverter, ) from metricflow.plan_conversion.dataflow_to_sql import DataflowToSqlQueryPlanConverter -from metricflow.protocols.query_parameter import QueryParameterDimension, QueryParameterMetric +from metricflow.protocols.query_parameter import ( + GroupByQueryParameter, + MetricQueryParameter, + OrderByQueryParameter, + TimeDimensionQueryParameter, +) from metricflow.protocols.sql_client import SqlClient from metricflow.query.query_exceptions import InvalidQueryException from metricflow.query.query_parser import MetricFlowQueryParser @@ -98,15 +103,15 @@ class MetricFlowQueryRequest: request_id: MetricFlowRequestId metric_names: Optional[Sequence[str]] = None - metrics: Optional[Sequence[QueryParameterMetric]] = None + metrics: Optional[Sequence[MetricQueryParameter]] = None group_by_names: Optional[Sequence[str]] = None - group_by: Optional[Sequence[QueryParameterDimension]] = None + group_by: Optional[Tuple[Union[GroupByQueryParameter, TimeDimensionQueryParameter], ...]] = None limit: Optional[int] = None time_constraint_start: Optional[datetime.datetime] = None time_constraint_end: Optional[datetime.datetime] = None where_constraint: Optional[str] = None order_by_names: Optional[Sequence[str]] = None - order_by: Optional[Sequence[QueryParameterDimension]] = None + order_by: Optional[Sequence[OrderByQueryParameter]] = None output_table: Optional[str] = None sql_optimization_level: SqlQueryOptimizationLevel = SqlQueryOptimizationLevel.O4 query_type: MetricFlowQueryType = MetricFlowQueryType.METRIC @@ -114,15 +119,15 @@ class MetricFlowQueryRequest: @staticmethod def create_with_random_request_id( # noqa: D metric_names: Optional[Sequence[str]] = None, - metrics: Optional[Sequence[QueryParameterMetric]] = None, + metrics: Optional[Sequence[MetricQueryParameter]] = None, group_by_names: Optional[Sequence[str]] = None, - group_by: Optional[Sequence[QueryParameterDimension]] = None, + group_by: Optional[Tuple[Union[GroupByQueryParameter, TimeDimensionQueryParameter], ...]] = None, limit: Optional[int] = None, time_constraint_start: Optional[datetime.datetime] = None, time_constraint_end: Optional[datetime.datetime] = None, where_constraint: Optional[str] = None, order_by_names: Optional[Sequence[str]] = None, - order_by: Optional[Sequence[QueryParameterDimension]] = None, + order_by: Optional[Sequence[OrderByQueryParameter]] = None, output_table: Optional[str] = None, sql_optimization_level: SqlQueryOptimizationLevel = SqlQueryOptimizationLevel.O4, query_type: MetricFlowQueryType = MetricFlowQueryType.METRIC, @@ -286,9 +291,9 @@ def get_dimension_values( def explain_get_dimension_values( # noqa: D self, metric_names: Optional[List[str]] = None, - metrics: Optional[Sequence[QueryParameterMetric]] = None, + metrics: Optional[Sequence[MetricQueryParameter]] = None, get_group_by_values: Optional[str] = None, - group_by: Optional[QueryParameterDimension] = None, + group_by: Optional[Union[GroupByQueryParameter, TimeDimensionQueryParameter]] = None, time_constraint_start: Optional[datetime.datetime] = None, time_constraint_end: Optional[datetime.datetime] = None, ) -> MetricFlowExplainResult: @@ -682,9 +687,9 @@ def get_dimension_values( # noqa: D def explain_get_dimension_values( # noqa: D self, metric_names: Optional[List[str]] = None, - metrics: Optional[Sequence[QueryParameterMetric]] = None, + metrics: Optional[Sequence[MetricQueryParameter]] = None, get_group_by_values: Optional[str] = None, - group_by: Optional[QueryParameterDimension] = None, + group_by: Optional[Union[GroupByQueryParameter, TimeDimensionQueryParameter]] = None, time_constraint_start: Optional[datetime.datetime] = None, time_constraint_end: Optional[datetime.datetime] = None, ) -> MetricFlowExplainResult: @@ -695,8 +700,8 @@ def explain_get_dimension_values( # noqa: D MetricFlowQueryRequest.create_with_random_request_id( metric_names=metric_names, metrics=metrics, - group_by_names=[get_group_by_values] if get_group_by_values else None, - group_by=[group_by] if group_by else None, + group_by_names=(get_group_by_values,) if get_group_by_values else None, + group_by=(group_by,) if group_by else None, time_constraint_start=time_constraint_start, time_constraint_end=time_constraint_end, query_type=MetricFlowQueryType.DIMENSION_VALUES, diff --git a/metricflow/protocols/query_parameter.py b/metricflow/protocols/query_parameter.py index 2b6b4e02bc..f2e27ec393 100644 --- a/metricflow/protocols/query_parameter.py +++ b/metricflow/protocols/query_parameter.py @@ -1,28 +1,42 @@ from __future__ import annotations -from typing import Optional, Protocol +from typing import Optional, Protocol, Union, runtime_checkable from dbt_semantic_interfaces.type_enums import TimeGranularity from metricflow.time.date_part import DatePart -class QueryParameterDimension(Protocol): - """A query parameter with a grain.""" +@runtime_checkable +class MetricQueryParameter(Protocol): + """Metric requested in a query.""" @property def name(self) -> str: - """The name of the item.""" + """The name of the metric.""" raise NotImplementedError + +@runtime_checkable +class GroupByQueryParameter(Protocol): + """Generic group by parameter for queries. Might be an entity or a dimension.""" + @property - def grain(self) -> Optional[TimeGranularity]: - """The time granularity.""" + def name(self) -> str: + """The name of the metric.""" raise NotImplementedError + +@runtime_checkable +class TimeDimensionQueryParameter(Protocol): # noqa: D @property - def descending(self) -> bool: - """Set the sort order for order-by.""" + def name(self) -> str: + """The name of the item.""" + raise NotImplementedError + + @property + def grain(self) -> Optional[TimeGranularity]: + """The time granularity.""" raise NotImplementedError @property @@ -31,15 +45,15 @@ def date_part(self) -> Optional[DatePart]: raise NotImplementedError -class QueryParameterMetric(Protocol): - """Metric in the query interface.""" +class OrderByQueryParameter(Protocol): + """Parameter to order by, specifying ascending or descending.""" @property - def name(self) -> str: - """The name of the metric.""" + def order_by(self) -> Union[MetricQueryParameter, GroupByQueryParameter, TimeDimensionQueryParameter]: + """Parameter to order results by.""" raise NotImplementedError @property def descending(self) -> bool: - """Set the sort order for order-by.""" + """Indicates if the order should be ascending or descending.""" raise NotImplementedError diff --git a/metricflow/query/query_parser.py b/metricflow/query/query_parser.py index a8e7deda0b..d4e6040b84 100644 --- a/metricflow/query/query_parser.py +++ b/metricflow/query/query_parser.py @@ -4,7 +4,7 @@ import logging import time from dataclasses import dataclass -from typing import Dict, List, Optional, Sequence, Tuple +from typing import Dict, List, Optional, Sequence, Tuple, Union from dbt_semantic_interfaces.call_parameter_sets import ParseWhereFilterException from dbt_semantic_interfaces.implementations.filters.where_filter import PydanticWhereFilter @@ -28,7 +28,12 @@ from metricflow.filters.time_constraint import TimeRangeConstraint from metricflow.model.semantic_manifest_lookup import SemanticManifestLookup from metricflow.naming.linkable_spec_name import StructuredLinkableSpecName -from metricflow.protocols.query_parameter import QueryParameterDimension, QueryParameterMetric +from metricflow.protocols.query_parameter import ( + GroupByQueryParameter, + MetricQueryParameter, + OrderByQueryParameter, + TimeDimensionQueryParameter, +) from metricflow.query.query_exceptions import InvalidQueryException from metricflow.specs.column_assoc import ColumnAssociationResolver from metricflow.specs.specs import ( @@ -169,16 +174,16 @@ def _top_fuzzy_matches( def parse_and_validate_query( self, metric_names: Optional[Sequence[str]] = None, - metrics: Optional[Sequence[QueryParameterMetric]] = None, + metrics: Optional[Sequence[MetricQueryParameter]] = None, group_by_names: Optional[Sequence[str]] = None, - group_by: Optional[Sequence[QueryParameterDimension]] = None, + group_by: Optional[Tuple[Union[GroupByQueryParameter, TimeDimensionQueryParameter], ...]] = None, limit: Optional[int] = None, time_constraint_start: Optional[datetime.datetime] = None, time_constraint_end: Optional[datetime.datetime] = None, where_constraint: Optional[WhereFilter] = None, where_constraint_str: Optional[str] = None, order: Optional[Sequence[str]] = None, - order_by: Optional[Sequence[QueryParameterDimension]] = None, + order_by: Optional[Sequence[OrderByQueryParameter]] = None, time_granularity: Optional[TimeGranularity] = None, ) -> MetricFlowQuerySpec: """Parse the query into spec objects, validating them in the process. @@ -290,7 +295,7 @@ def _construct_metric_specs_for_query( return tuple(metric_specs) def _get_metric_names( - self, metric_names: Optional[Sequence[str]], metrics: Optional[Sequence[QueryParameterMetric]] + self, metric_names: Optional[Sequence[str]], metrics: Optional[Sequence[MetricQueryParameter]] ) -> Sequence[str]: assert_exactly_one_arg_set(metric_names=metric_names, metrics=metrics) return metric_names if metric_names else [m.name for m in metrics] if metrics else [] @@ -308,7 +313,7 @@ def _get_where_filter( ) def _get_order( - self, order: Optional[Sequence[str]], order_by: Optional[Sequence[QueryParameterDimension]] + self, order: Optional[Sequence[str]], order_by: Optional[Sequence[OrderByQueryParameter]] ) -> Sequence[str]: assert not ( order and order_by @@ -318,16 +323,16 @@ def _get_order( def _parse_and_validate_query( self, metric_names: Optional[Sequence[str]] = None, - metrics: Optional[Sequence[QueryParameterMetric]] = None, + metrics: Optional[Sequence[MetricQueryParameter]] = None, group_by_names: Optional[Sequence[str]] = None, - group_by: Optional[Sequence[QueryParameterDimension]] = None, + group_by: Optional[Tuple[Union[GroupByQueryParameter, TimeDimensionQueryParameter], ...]] = None, limit: Optional[int] = None, time_constraint_start: Optional[datetime.datetime] = None, time_constraint_end: Optional[datetime.datetime] = None, where_constraint: Optional[WhereFilter] = None, where_constraint_str: Optional[str] = None, order: Optional[Sequence[str]] = None, - order_by: Optional[Sequence[QueryParameterDimension]] = None, + order_by: Optional[Sequence[OrderByQueryParameter]] = None, time_granularity: Optional[TimeGranularity] = None, ) -> MetricFlowQuerySpec: metric_names = self._get_metric_names(metric_names, metrics) @@ -380,8 +385,8 @@ def _parse_and_validate_query( # If the time constraint is all time, just ignore and not render time_constraint = None - requested_linkable_specs = self._parse_linkable_elements( - qualified_linkable_names=group_by_names, linkable_elements=group_by, metric_references=metric_references + requested_linkable_specs = self._parse_group_by( + group_by_names=group_by_names, group_by=group_by, metric_references=metric_references ) where_filter_spec: Optional[WhereFilterSpec] = None if where_filter is not None: @@ -426,9 +431,9 @@ def _parse_and_validate_query( for metric_reference in metric_references: metric = self._metric_lookup.get_metric(metric_reference) if metric.filter is not None: - group_by_specs_for_one_metric = self._parse_linkable_elements( - qualified_linkable_names=group_by_names, - linkable_elements=group_by, + group_by_specs_for_one_metric = self._parse_group_by( + group_by_names=group_by_names, + group_by=group_by, metric_references=(metric_reference,), ) @@ -663,30 +668,32 @@ def _parse_metric_names( metric_references.extend(list(input_metrics)) return tuple(metric_references) - def _parse_linkable_elements( + def _parse_group_by( self, metric_references: Sequence[MetricReference], - qualified_linkable_names: Optional[Sequence[str]] = None, - linkable_elements: Optional[Sequence[QueryParameterDimension]] = None, + group_by_names: Optional[Sequence[str]] = None, + group_by: Optional[Tuple[Union[GroupByQueryParameter, TimeDimensionQueryParameter], ...]] = None, ) -> QueryTimeLinkableSpecSet: """Convert the linkable spec names into the respective specification objects.""" # TODO: refactor to only support group_by object inputs (removing group_by_names param) assert not ( - qualified_linkable_names and linkable_elements + group_by_names and group_by ), "Both group_by_names and group_by were set, but if a group by is specified you should only use one of these!" structured_names: List[StructuredLinkableSpecName] = [] - if qualified_linkable_names: - qualified_linkable_names = [x.lower() for x in qualified_linkable_names] - structured_names = [StructuredLinkableSpecName.from_name(name) for name in qualified_linkable_names] - elif linkable_elements: - for linkable_element in linkable_elements: - parsed_name = StructuredLinkableSpecName.from_name(linkable_element.name) + if group_by_names: + group_by_names = [x.lower() for x in group_by_names] + structured_names = [StructuredLinkableSpecName.from_name(name) for name in group_by_names] + elif group_by: + for group_by_obj in group_by: + parsed_name = StructuredLinkableSpecName.from_name(group_by_obj.name) structured_name = StructuredLinkableSpecName( entity_link_names=parsed_name.entity_link_names, element_name=parsed_name.element_name, - time_granularity=linkable_element.grain, - date_part=linkable_element.date_part, + time_granularity=group_by_obj.grain + if isinstance(group_by_obj, TimeDimensionQueryParameter) + else None, + date_part=group_by_obj.date_part if isinstance(group_by_obj, TimeDimensionQueryParameter) else None, ) structured_names.append(structured_name) @@ -729,15 +736,12 @@ def _parse_linkable_elements( valid_group_bys_for_metrics = self._metric_lookup.element_specs_for_metrics(list(metric_references)) valid_group_by_names_for_metrics = sorted( list( - set( - x.qualified_name if qualified_linkable_names else x.element_name - for x in valid_group_bys_for_metrics - ) + set(x.qualified_name if group_by_names else x.element_name for x in valid_group_bys_for_metrics) ) ) # If requested by name, show qualified name. If requested as object, show element name. - display_name = structured_name.qualified_name if qualified_linkable_names else element_name + display_name = structured_name.qualified_name if group_by_names else element_name suggestions = { f"Suggestions for '{display_name}'": pformat_big_objects( MetricFlowQueryParser._top_fuzzy_matches( @@ -748,7 +752,7 @@ def _parse_linkable_elements( } raise UnableToSatisfyQueryError( f"Unknown element name '{element_name}' in dimension name '{display_name}'" - if qualified_linkable_names + if group_by_names else f"Unknown dimension {element_name}", context=suggestions, ) diff --git a/metricflow/specs/query_param_implementations.py b/metricflow/specs/query_param_implementations.py index b791c46189..093a974f0e 100644 --- a/metricflow/specs/query_param_implementations.py +++ b/metricflow/specs/query_param_implementations.py @@ -1,24 +1,57 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional +from typing import Optional, Union from dbt_semantic_interfaces.type_enums.time_granularity import TimeGranularity from metricflow.naming.linkable_spec_name import StructuredLinkableSpecName +from metricflow.protocols.query_parameter import ( + GroupByQueryParameter, + MetricQueryParameter, + TimeDimensionQueryParameter, +) from metricflow.time.date_part import DatePart @dataclass(frozen=True) -class DimensionQueryParameter: +class TimeDimensionParameter: """Time dimension requested in a query.""" name: str grain: Optional[TimeGranularity] = None - descending: bool = False date_part: Optional[DatePart] = None def __post_init__(self) -> None: # noqa: D parsed_name = StructuredLinkableSpecName.from_name(self.name) if parsed_name.time_granularity: raise ValueError("Must use object syntax for `grain` parameter if `date_part` is requested.") + + +@dataclass(frozen=True) +class GroupByParameter: + """Group by parameter requested in a query. + + Might represent an entity or a dimension. + """ + + name: str + + +@dataclass(frozen=True) +class MetricParameter: + """Metric requested in a query.""" + + name: str + + +@dataclass(frozen=True) +class OrderByParameter: + """Order by requested in a query.""" + + order_by: Union[MetricQueryParameter, GroupByQueryParameter, TimeDimensionQueryParameter] + descending: bool = False + + +# Do we want one generic type for QueryParameter which includes grain & date_part? +# The main question: do we need to know what type we're passing into MF? Or are we ok with MF just figuring it out? diff --git a/metricflow/test/conftest.py b/metricflow/test/conftest.py index d2a60d713c..31cb6d558e 100644 --- a/metricflow/test/conftest.py +++ b/metricflow/test/conftest.py @@ -1,11 +1,6 @@ # These imports are required to properly set up pytest fixtures. from __future__ import annotations -from dataclasses import dataclass -from typing import Optional - -from dbt_semantic_interfaces.type_enums.time_granularity import TimeGranularity - from metricflow.test.fixtures.cli_fixtures import * # noqa: F401, F403 from metricflow.test.fixtures.dataflow_fixtures import * # noqa: F401, F403 from metricflow.test.fixtures.id_fixtures import * # noqa: F401, F403 @@ -14,14 +9,3 @@ from metricflow.test.fixtures.sql_client_fixtures import * # noqa: F401, F403 from metricflow.test.fixtures.sql_fixtures import * # noqa: F401, F403 from metricflow.test.fixtures.table_fixtures import * # noqa: F401, F403 -from metricflow.time.date_part import DatePart - - -@dataclass -class MockQueryParameterDimension: - """This is a mock that is just used to test the query parser.""" - - name: str - grain: Optional[TimeGranularity] = None - descending: bool = False - date_part: Optional[DatePart] = None diff --git a/metricflow/test/integration/test_configured_cases.py b/metricflow/test/integration/test_configured_cases.py index af897edcaa..366d8e5aa7 100644 --- a/metricflow/test/integration/test_configured_cases.py +++ b/metricflow/test/integration/test_configured_cases.py @@ -18,8 +18,9 @@ from metricflow.plan_conversion.column_resolver import ( DunderColumnAssociationResolver, ) +from metricflow.protocols.query_parameter import GroupByQueryParameter from metricflow.protocols.sql_client import SqlClient -from metricflow.specs.query_param_implementations import DimensionQueryParameter +from metricflow.specs.query_param_implementations import GroupByParameter, TimeDimensionParameter from metricflow.sql.sql_exprs import ( SqlCastToTimestampExpression, SqlColumnReference, @@ -255,16 +256,19 @@ def test_case( check_query_helpers = CheckQueryHelpers(sql_client) - group_by: List[DimensionQueryParameter] = [] + group_by: List[GroupByQueryParameter] = [] for group_by_kwargs in case.group_by_objs: kwargs = copy(group_by_kwargs) date_part = kwargs.get("date_part") grain = kwargs.get("grain") - if date_part: - kwargs["date_part"] = DatePart(date_part) - if grain: - kwargs["grain"] = TimeGranularity(grain) - group_by.append(DimensionQueryParameter(**kwargs)) + if date_part or grain: + if date_part: + kwargs["date_part"] = DatePart(date_part) + if grain: + kwargs["grain"] = TimeGranularity(grain) + group_by.append(TimeDimensionParameter(**kwargs)) + else: + group_by.append(GroupByParameter(**kwargs)) query_result = engine.query( MetricFlowQueryRequest.create_with_random_request_id( metric_names=case.metrics, diff --git a/metricflow/test/query/test_query_parser.py b/metricflow/test/query/test_query_parser.py index e861369302..6f49adf132 100644 --- a/metricflow/test/query/test_query_parser.py +++ b/metricflow/test/query/test_query_parser.py @@ -14,6 +14,12 @@ from metricflow.filters.time_constraint import TimeRangeConstraint from metricflow.query.query_exceptions import InvalidQueryException from metricflow.query.query_parser import MetricFlowQueryParser +from metricflow.specs.query_param_implementations import ( + GroupByParameter, + MetricParameter, + OrderByParameter, + TimeDimensionParameter, +) from metricflow.specs.specs import ( DimensionSpec, EntitySpec, @@ -21,7 +27,6 @@ OrderBySpec, TimeDimensionSpec, ) -from metricflow.test.conftest import MockQueryParameterDimension from metricflow.test.fixtures.model_fixtures import query_parser_from_yaml from metricflow.test.model.example_project_configuration import EXAMPLE_PROJECT_CONFIGURATION_YAML_CONFIG_FILE from metricflow.test.time.metric_time_dimension import MTD @@ -188,12 +193,15 @@ def test_query_parser(bookings_query_parser: MetricFlowQueryParser) -> None: # def test_query_parser_with_object_params(bookings_query_parser: MetricFlowQueryParser) -> None: # noqa: D Metric = namedtuple("Metric", ["name", "descending"]) metric = Metric("bookings", False) - group_by = [ - MockQueryParameterDimension("booking__is_instant"), - MockQueryParameterDimension("listing"), - MockQueryParameterDimension(MTD), - ] - order_by = [MockQueryParameterDimension(MTD), MockQueryParameterDimension("-bookings")] + group_by = ( + GroupByParameter("booking__is_instant"), + GroupByParameter("listing"), + TimeDimensionParameter(MTD), + ) + order_by = ( + OrderByParameter(order_by=TimeDimensionParameter(MTD)), + OrderByParameter(order_by=MetricParameter("bookings"), descending=True), + ) query_spec = bookings_query_parser.parse_and_validate_query(metrics=[metric], group_by=group_by, order_by=order_by) assert query_spec.metric_specs == (MetricSpec(element_name="bookings"),) assert query_spec.dimension_specs == ( @@ -414,34 +422,34 @@ def test_date_part_parsing() -> None: with pytest.raises(RequestTimeGranularityException): query_parser.parse_and_validate_query( metric_names=["revenue"], - group_by=[MockQueryParameterDimension(name="metric_time", date_part=DatePart.DOW)], + group_by=(TimeDimensionParameter(name="metric_time", date_part=DatePart.DOW),), ) # Can't query date part for cumulative metrics with pytest.raises(UnableToSatisfyQueryError): query_parser.parse_and_validate_query( metric_names=["revenue_cumulative"], - group_by=[MockQueryParameterDimension(name="metric_time", date_part=DatePart.YEAR)], + group_by=(TimeDimensionParameter(name="metric_time", date_part=DatePart.YEAR),), ) # Can't query date part for metrics with offset to grain with pytest.raises(UnableToSatisfyQueryError): query_parser.parse_and_validate_query( metric_names=["revenue_since_start_of_year"], - group_by=[MockQueryParameterDimension(name="metric_time", date_part=DatePart.MONTH)], + group_by=(TimeDimensionParameter(name="metric_time", date_part=DatePart.MONTH),), ) # Requested granularity doesn't match resolved granularity with pytest.raises(RequestTimeGranularityException): query_parser.parse_and_validate_query( metric_names=["revenue"], - group_by=[ - MockQueryParameterDimension(name="metric_time", grain=TimeGranularity.YEAR, date_part=DatePart.MONTH) - ], + group_by=( + TimeDimensionParameter(name="metric_time", grain=TimeGranularity.YEAR, date_part=DatePart.MONTH), + ), ) # Date part is compatible query_parser.parse_and_validate_query( metric_names=["revenue"], - group_by=[MockQueryParameterDimension(name="metric_time", date_part=DatePart.MONTH)], + group_by=(TimeDimensionParameter(name="metric_time", date_part=DatePart.MONTH),), )