Skip to content

Commit 1d31380

Browse files
Add new-syle YAML (Parsed) Metric
1 parent f5cdb1d commit 1d31380

File tree

5 files changed

+169
-64
lines changed

5 files changed

+169
-64
lines changed

core/dbt/artifacts/resources/v1/metric.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,11 @@ class ConstantPropertyInput(dbtClassMixin):
8383

8484
@dataclass
8585
class ConversionTypeParams(dbtClassMixin):
86-
base_measure: MetricInputMeasure
87-
conversion_measure: MetricInputMeasure
8886
entity: str
87+
base_measure: Optional[MetricInputMeasure] = None
88+
conversion_measure: Optional[MetricInputMeasure] = None
89+
base_metric: Optional[MetricInput] = None
90+
conversion_metric: Optional[MetricInput] = None
8991
calculation: ConversionCalculationType = ConversionCalculationType.CONVERSION_RATE
9092
window: Optional[MetricTimeWindow] = None
9193
constant_properties: Optional[List[ConstantPropertyInput]] = None
@@ -106,12 +108,13 @@ class MetricAggregationParams(dbtClassMixin):
106108
agg_params: Optional[MeasureAggregationParameters] = None
107109
agg_time_dimension: Optional[str] = None
108110
non_additive_dimension: Optional[NonAdditiveDimension] = None
109-
expr: Optional[str] = None
110111

111112

112113
@dataclass
113114
class MetricTypeParams(dbtClassMixin):
115+
# Only used in old-style YAML
114116
measure: Optional[MetricInputMeasure] = None
117+
# Only used in new-style JSON
115118
input_measures: List[MetricInputMeasure] = field(default_factory=list)
116119
numerator: Optional[MetricInput] = None
117120
denominator: Optional[MetricInput] = None
@@ -123,7 +126,12 @@ class MetricTypeParams(dbtClassMixin):
123126
metrics: Optional[List[MetricInput]] = None
124127
conversion_type_params: Optional[ConversionTypeParams] = None
125128
cumulative_type_params: Optional[CumulativeTypeParams] = None
129+
130+
# Only used in v2 YAML
126131
metric_aggregation_params: Optional[MetricAggregationParams] = None
132+
fill_nulls_with: Optional[int] = None
133+
join_to_timespine: Optional[bool] = None
134+
is_private: Optional[bool] = None # populated by "hidden" field in YAML
127135

128136

129137
@dataclass
@@ -148,8 +156,6 @@ class Metric(GraphResource):
148156
metadata: Optional[SourceFileMetadata] = None
149157
time_granularity: Optional[str] = None
150158
resource_type: Literal[NodeType.Metric]
151-
meta: Dict[str, Any] = field(default_factory=dict, metadata=MergeBehavior.Update.meta())
152-
tags: List[str] = field(default_factory=list)
153159
config: MetricConfig = field(default_factory=MetricConfig)
154160
unrendered_config: Dict[str, Any] = field(default_factory=dict)
155161
sources: List[List[str]] = field(default_factory=list)
@@ -159,14 +165,18 @@ class Metric(GraphResource):
159165
created_at: float = field(default_factory=lambda: time.time())
160166
group: Optional[str] = None
161167

168+
# These fields are only used in v1 metrics.
169+
meta: Dict[str, Any] = field(default_factory=dict, metadata=MergeBehavior.Update.meta())
170+
tags: List[str] = field(default_factory=list)
171+
172+
@property
173+
def input_metrics(self) -> List[MetricInput]:
174+
return self.type_params.metrics or []
175+
162176
@property
163177
def input_measures(self) -> List[MetricInputMeasure]:
164178
return self.type_params.input_measures
165179

166180
@property
167181
def measure_references(self) -> List[MeasureReference]:
168182
return [x.measure_reference() for x in self.input_measures]
169-
170-
@property
171-
def input_metrics(self) -> List[MetricInput]:
172-
return self.type_params.metrics or []

core/dbt/contracts/graph/unparsed.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -580,9 +580,12 @@ class UnparsedMetricInput(dbtClassMixin):
580580

581581
@dataclass
582582
class UnparsedConversionTypeParams(dbtClassMixin):
583-
base_measure: Union[UnparsedMetricInputMeasure, str]
584-
conversion_measure: Union[UnparsedMetricInputMeasure, str]
585583
entity: str
584+
# TODO : add validation that we have input measures XOR input metrics
585+
base_measure: Optional[Union[UnparsedMetricInputMeasure, str]] = None
586+
conversion_measure: Optional[Union[UnparsedMetricInputMeasure, str]] = None
587+
base_metric: Optional[Union[UnparsedMetricInput, str]] = None
588+
conversion_metric: Optional[Union[UnparsedMetricInput, str]] = None
586589
calculation: str = (
587590
ConversionCalculationType.CONVERSION_RATE.value
588591
) # ConversionCalculationType Enum
@@ -674,30 +677,30 @@ class UnparsedMetricV2(UnparsedMetricBase):
674677

675678
join_to_timespine: Optional[bool] = None
676679
fill_nulls_with: Optional[int] = None
677-
expr: Optional[Union[str, int]] = None
680+
expr: Optional[Union[str, bool]] = None
678681

679682
non_additive_dimension: Optional[UnparsedNonAdditiveDimensionV2] = None
680683
agg_time_dimension: Optional[str] = None
681684

682685
# For cumulative metrics
683686
window: Optional[str] = None
684687
grain_to_date: Optional[str] = None
685-
period_agg: Optional[str] = None
688+
period_agg: str = PeriodAggregation.FIRST.value
686689
input_metric: Optional[Union[str, Dict[str, Any]]] = None
687690

688691
# For ratio metrics
689-
numerator: Optional[Union[str, Dict[str, Any]]] = None
690-
denominator: Optional[Union[str, Dict[str, Any]]] = None
692+
numerator: Optional[Union[UnparsedMetricInput, str]] = None
693+
denominator: Optional[Union[UnparsedMetricInput, str]] = None
691694

692695
# For derived metrics
693-
input_metrics: Optional[List[Dict[str, Any]]] = None
696+
input_metrics: Optional[List[Union[UnparsedMetricInput, str]]] = None
694697

695698
# For conversion metrics
696699
entity: Optional[str] = None
697700
calculation: Optional[str] = None
698-
base_metric: Optional[Union[str, Dict[str, Any]]] = None
699-
conversion_metric: Optional[Union[str, Dict[str, Any]]] = None
700-
constant_properties: Optional[List[Dict[str, Any]]] = None
701+
base_metric: Optional[Union[UnparsedMetricInput, str]] = None
702+
conversion_metric: Optional[Union[UnparsedMetricInput, str]] = None
703+
constant_properties: Optional[List[ConstantPropertyInput]] = None
701704

702705

703706
@dataclass

core/dbt/parser/schema_yaml_readers.py

Lines changed: 134 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@
4646
UnparsedGroup,
4747
UnparsedMeasure,
4848
UnparsedMetric,
49+
UnparsedMetricBase,
4950
UnparsedMetricInput,
5051
UnparsedMetricInputMeasure,
5152
UnparsedMetricTypeParams,
53+
UnparsedMetricV2,
5254
UnparsedNonAdditiveDimension,
5355
UnparsedQueryParams,
5456
UnparsedSavedQuery,
@@ -72,7 +74,7 @@
7274

7375

7476
def parse_where_filter(
75-
where: Optional[Union[List[str], str]]
77+
where: Optional[Union[List[str], str]],
7678
) -> Optional[WhereFilterIntersection]:
7779
if where is None:
7880
return None
@@ -306,11 +308,19 @@ def _get_metric_inputs(
306308

307309
return metric_inputs
308310

309-
def _get_optional_conversion_type_params(
311+
def _get_optional_v1_conversion_type_params(
310312
self, unparsed: Optional[UnparsedConversionTypeParams]
311313
) -> Optional[ConversionTypeParams]:
312314
if unparsed is None:
313315
return None
316+
if unparsed.base_measure is None:
317+
raise ValidationError(
318+
"base_measure is required for conversion metrics that use type_params."
319+
)
320+
if unparsed.conversion_measure is None:
321+
raise ValidationError(
322+
"conversion_measure is required for conversion metrics that use type_params."
323+
)
314324
return ConversionTypeParams(
315325
base_measure=self._get_input_measure(unparsed.base_measure),
316326
conversion_measure=self._get_input_measure(unparsed.conversion_measure),
@@ -320,7 +330,30 @@ def _get_optional_conversion_type_params(
320330
constant_properties=unparsed.constant_properties,
321331
)
322332

323-
def _get_optional_cumulative_type_params(
333+
def _get_optional_v2_conversion_type_params(
334+
self,
335+
unparsed_metric: UnparsedMetricV2,
336+
) -> Optional[ConversionTypeParams]:
337+
if MetricType(unparsed_metric.type) is not MetricType.CONVERSION:
338+
return None
339+
# TODO: shoudl we also make a best effort to validate this in the UNPARSED class?
340+
# It'd violate DRY, but it is probably the more appropriate place to do the validation.
341+
if unparsed_metric.base_metric is None:
342+
raise ValidationError("base_metric is required for cumulative metrics.")
343+
if unparsed_metric.conversion_metric is None:
344+
raise ValidationError("conversion_metric is required for cumulative metrics.")
345+
if unparsed_metric.entity is None:
346+
raise ValidationError("entity is required for conversion metrics.")
347+
return ConversionTypeParams(
348+
base_metric=self._get_metric_input(unparsed_metric.base_metric),
349+
conversion_metric=self._get_metric_input(unparsed_metric.conversion_metric),
350+
entity=unparsed_metric.entity,
351+
calculation=ConversionCalculationType(unparsed_metric.calculation),
352+
window=self._get_optional_time_window(unparsed_metric.window),
353+
constant_properties=unparsed_metric.constant_properties,
354+
)
355+
356+
def _get_optional_v1_cumulative_type_params(
324357
self, unparsed_metric: UnparsedMetric
325358
) -> Optional[CumulativeTypeParams]:
326359
unparsed_type_params = unparsed_metric.type_params
@@ -353,36 +386,71 @@ def _get_optional_cumulative_type_params(
353386

354387
return None
355388

356-
def _get_metric_type_params(self, unparsed_metric: UnparsedMetric) -> MetricTypeParams:
357-
type_params = unparsed_metric.type_params
358-
359-
grain_to_date: Optional[TimeGranularity] = None
360-
if type_params.grain_to_date is not None:
361-
# This should've been changed to a string (to support custom grain), but since this
362-
# is a legacy field waiting to be deprecated, we will not support custom grain here
363-
# in order to force customers off of using this field. The field to use should be
364-
# `cumulative_type_params.grain_to_date`
365-
grain_to_date = TimeGranularity(type_params.grain_to_date)
366-
367-
return MetricTypeParams(
368-
measure=self._get_optional_input_measure(type_params.measure),
369-
numerator=self._get_optional_metric_input(type_params.numerator),
370-
denominator=self._get_optional_metric_input(type_params.denominator),
371-
expr=str(type_params.expr) if type_params.expr is not None else None,
372-
window=self._get_optional_time_window(type_params.window),
373-
grain_to_date=grain_to_date,
374-
metrics=self._get_metric_inputs(type_params.metrics),
375-
conversion_type_params=self._get_optional_conversion_type_params(
376-
type_params.conversion_type_params
377-
),
378-
cumulative_type_params=self._get_optional_cumulative_type_params(
379-
unparsed_metric=unparsed_metric,
380-
),
381-
# input measures are calculated via metric processing post parsing
382-
# input_measures=?,
389+
def _get_optional_v2_cumulative_type_params(
390+
self,
391+
unparsed_metric: UnparsedMetricV2,
392+
) -> Optional[CumulativeTypeParams]:
393+
if MetricType(unparsed_metric.type) is not MetricType.CUMULATIVE:
394+
return None
395+
return CumulativeTypeParams(
396+
window=self._get_optional_time_window(unparsed_metric.window),
397+
grain_to_date=unparsed_metric.grain_to_date,
398+
period_agg=self._get_period_agg(unparsed_metric.period_agg),
383399
)
384400

385-
def parse_metric(self, unparsed: UnparsedMetric, generated_from: Optional[str] = None) -> None:
401+
def _get_metric_type_params(self, unparsed_metric: UnparsedMetricBase) -> MetricTypeParams:
402+
if isinstance(unparsed_metric, UnparsedMetric):
403+
type_params = unparsed_metric.type_params
404+
405+
grain_to_date: Optional[TimeGranularity] = None
406+
if type_params.grain_to_date is not None:
407+
# This should've been changed to a string (to support custom grain), but since this
408+
# is a legacy field waiting to be deprecated, we will not support custom grain here
409+
# in order to force customers off of using this field. The field to use should be
410+
# `cumulative_type_params.grain_to_date`
411+
grain_to_date = TimeGranularity(type_params.grain_to_date)
412+
413+
return MetricTypeParams(
414+
measure=self._get_optional_input_measure(type_params.measure),
415+
numerator=self._get_optional_metric_input(type_params.numerator),
416+
denominator=self._get_optional_metric_input(type_params.denominator),
417+
expr=str(type_params.expr) if type_params.expr is not None else None,
418+
window=self._get_optional_time_window(type_params.window),
419+
grain_to_date=grain_to_date,
420+
metrics=self._get_metric_inputs(type_params.metrics),
421+
conversion_type_params=self._get_optional_v1_conversion_type_params(
422+
type_params.conversion_type_params
423+
),
424+
cumulative_type_params=self._get_optional_v1_cumulative_type_params(
425+
unparsed_metric=unparsed_metric,
426+
),
427+
# input measures are calculated via metric processing post parsing
428+
# input_measures=?,
429+
)
430+
elif isinstance(unparsed_metric, UnparsedMetricV2):
431+
# if unparsed_metric.type.lower() == MetricType.CONVERSION.value:
432+
return MetricTypeParams(
433+
numerator=self._get_optional_metric_input(unparsed_metric.numerator),
434+
denominator=self._get_optional_metric_input(unparsed_metric.denominator),
435+
expr=str(unparsed_metric.expr) if unparsed_metric.expr is not None else None,
436+
window=self._get_optional_time_window(unparsed_metric.window),
437+
metrics=self._get_metric_inputs(unparsed_metric.input_metrics),
438+
conversion_type_params=self._get_optional_v2_conversion_type_params(
439+
unparsed_metric=unparsed_metric,
440+
),
441+
cumulative_type_params=self._get_optional_v2_cumulative_type_params(
442+
unparsed_metric=unparsed_metric,
443+
),
444+
)
445+
else:
446+
raise DbtInternalError(
447+
f"Tried to parse type params for a {type(unparsed_metric)}, but expected "
448+
"an UnparsedMetric or UnparsedMetricV2",
449+
)
450+
451+
def parse_metric(
452+
self, unparsed: UnparsedMetricBase, generated_from: Optional[str] = None
453+
) -> None:
386454
package_name = self.project.project_name
387455
unique_id = f"{NodeType.Metric}.{package_name}.{unparsed.name}"
388456
path = self.yaml.path.relative_path
@@ -411,10 +479,23 @@ def parse_metric(self, unparsed: UnparsedMetric, generated_from: Optional[str] =
411479
f"Calculated a {type(config)} for a metric, but expected a MetricConfig"
412480
)
413481

414-
# If we have meta in the config, copy to node level, for backwards
415-
# compatibility with earlier node-only config.
416-
if "meta" in config and config["meta"]:
417-
unparsed.meta = config["meta"]
482+
if isinstance(unparsed, UnparsedMetric):
483+
# If we have meta in the config, copy to node level, for backwards
484+
# compatibility with earlier node-only config.
485+
if "meta" in config and config["meta"]:
486+
unparsed.meta = config["meta"]
487+
meta = unparsed.meta
488+
tags = unparsed.tags
489+
elif isinstance(unparsed, UnparsedMetricV2):
490+
# V2 Metrics do not have a top-level meta field; this should be part of
491+
# the config.
492+
meta = {}
493+
tags = []
494+
else:
495+
raise DbtInternalError(
496+
f"Tried to parse a {type(unparsed)} into a metric, but expected "
497+
"an UnparsedMetric or UnparsedMetricV2",
498+
)
418499

419500
parsed = Metric(
420501
resource_type=NodeType.Metric,
@@ -425,13 +506,13 @@ def parse_metric(self, unparsed: UnparsedMetric, generated_from: Optional[str] =
425506
fqn=fqn,
426507
name=unparsed.name,
427508
description=unparsed.description,
428-
label=unparsed.label,
509+
label=unparsed.label or unparsed.name,
429510
type=MetricType(unparsed.type),
430511
type_params=self._get_metric_type_params(unparsed),
431512
time_granularity=unparsed.time_granularity,
432513
filter=parse_where_filter(unparsed.filter),
433-
meta=unparsed.meta,
434-
tags=unparsed.tags,
514+
meta=meta,
515+
tags=tags,
435516
config=config,
436517
unrendered_config=unrendered_config,
437518
group=config.group,
@@ -445,7 +526,7 @@ def parse_metric(self, unparsed: UnparsedMetric, generated_from: Optional[str] =
445526
self.manifest.add_disabled(self.yaml.file, parsed)
446527

447528
def _generate_metric_config(
448-
self, target: UnparsedMetric, fqn: List[str], package_name: str, rendered: bool
529+
self, target: UnparsedMetricBase, fqn: List[str], package_name: str, rendered: bool
449530
):
450531
generator: BaseContextConfigGenerator
451532
if rendered:
@@ -470,12 +551,20 @@ def _generate_metric_config(
470551

471552
def parse(self) -> None:
472553
for data in self.get_key_dicts():
473-
try:
474-
UnparsedMetric.validate(data)
475-
unparsed = UnparsedMetric.from_dict(data)
476-
477-
except (ValidationError, JSONValidationError) as exc:
478-
raise YamlParseDictError(self.yaml.path, self.key, data, exc)
554+
# The main differentiator of old-style yaml and new-style is "type_params",
555+
# so if that is missing, we'll assume you're using the newer yaml.
556+
if "type_params" in data:
557+
try:
558+
UnparsedMetric.validate(data)
559+
unparsed = UnparsedMetric.from_dict(data)
560+
except (ValidationError, JSONValidationError) as exc:
561+
raise YamlParseDictError(self.yaml.path, self.key, data, exc)
562+
else:
563+
try:
564+
UnparsedMetricV2.validate(data)
565+
unparsed = UnparsedMetricV2.from_dict(data)
566+
except (ValidationError, JSONValidationError) as exc:
567+
raise YamlParseDictError(self.yaml.path, self.key, data, exc)
479568
self.parse_metric(unparsed)
480569

481570

tests/functional/semantic_models/test_semantic_model_parsing.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
schema_yml,
1616
)
1717

18+
# TODO ADD TESTS HERE FOR V2 Semantic Models
19+
1820

1921
class TestSemanticModelParsing:
2022
@pytest.fixture(scope="class")

tests/unit/contracts/graph/test_unparsed.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,7 @@ def test_ok(self):
10161016
dct = self.get_ok_dict()
10171017
# add defaults:
10181018
dct["hidden"] = False
1019+
dct["period_agg"] = "first"
10191020
self.assert_symmetric(metric, dct)
10201021
pickle.loads(pickle.dumps(metric))
10211022

0 commit comments

Comments
 (0)