Skip to content

Commit 4986d43

Browse files
Add new-syle YAML metric
1 parent 77e1c7d commit 4986d43

File tree

3 files changed

+188
-50
lines changed

3 files changed

+188
-50
lines changed

core/dbt/contracts/graph/unparsed.py

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -610,12 +610,37 @@ class UnparsedMetricTypeParams(dbtClassMixin):
610610
cumulative_type_params: Optional[UnparsedCumulativeTypeParams] = None
611611

612612

613+
class UnparsedMetricBase(dbtClassMixin):
614+
@classmethod
615+
def validate(cls, data):
616+
super().validate(data)
617+
if "name" in data:
618+
errors = []
619+
if " " in data["name"]:
620+
errors.append("cannot contain spaces")
621+
# This handles failing queries due to too long metric names.
622+
# It only occurs in BigQuery and Snowflake (Postgres/Redshift truncate)
623+
if len(data["name"]) > 250:
624+
errors.append("cannot contain more than 250 characters")
625+
if not (re.match(r"^[A-Za-z]", data["name"])):
626+
errors.append("must begin with a letter")
627+
if not (re.match(r"[\w]+$", data["name"])):
628+
errors.append("must contain only letters, numbers and underscores")
629+
630+
if errors:
631+
raise ValidationError(
632+
f"The metric name '{data['name']}' is invalid. It {', '.join(e for e in errors)}"
633+
)
634+
635+
613636
@dataclass
614-
class UnparsedMetric(dbtClassMixin):
637+
class UnparsedMetric(UnparsedMetricBase):
638+
"""Old-style YAML metric; prefer UnparsedMetricV2 instead as of late 2025."""
639+
615640
name: str
616641
label: str
617642
type: str
618-
type_params: UnparsedMetricTypeParams
643+
type_params: UnparsedMetricTypeParams # old-style YAML
619644
description: str = ""
620645
# Note: `Union` must be the outermost part of the type annotation for serialization to work properly.
621646
filter: Union[str, List[str], None] = None
@@ -625,24 +650,73 @@ class UnparsedMetric(dbtClassMixin):
625650
tags: List[str] = field(default_factory=list)
626651
config: Dict[str, Any] = field(default_factory=dict)
627652

653+
654+
@dataclass
655+
class UnparsedNonAdditiveDimensionV2(dbtClassMixin):
656+
name: str
657+
window_agg: str # AggregationType enum
658+
group_by: List[str] = field(default_factory=list)
659+
660+
661+
@dataclass
662+
class UnparsedMetricV2(UnparsedMetricBase):
663+
name: str
664+
label: Optional[str] = None
665+
hidden: bool = False
666+
description: Optional[str] = None
667+
type: Optional[str] = "simple"
668+
agg: Optional[str] = None
669+
670+
percentile: Optional[float] = None
671+
percentile_type: Optional[str] = None
672+
673+
join_to_timespine: Optional[bool] = None
674+
fill_nulls_with: Optional[int] = None
675+
expr: Optional[Union[str, int]] = None
676+
filter: Union[str, List[str], None] = None
677+
678+
tags: List[str] = field(default_factory=list)
679+
meta: Dict[str, Any] = field(default_factory=dict)
680+
config: Dict[str, Any] = field(default_factory=dict)
681+
682+
non_additive_dimension: Optional[UnparsedNonAdditiveDimensionV2] = None
683+
agg_time_dimension: Optional[str] = None
684+
685+
# For cumulative metrics
686+
window: Optional[str] = None
687+
grain_to_date: Optional[str] = None
688+
period_agg: Optional[str] = None
689+
input_metric: Optional[Union[str, Dict[str, Any]]] = None
690+
691+
# For ratio metrics
692+
numerator: Optional[Union[str, Dict[str, Any]]] = None
693+
denominator: Optional[Union[str, Dict[str, Any]]] = None
694+
695+
# For derived metrics
696+
input_metrics: Optional[List[Dict[str, Any]]] = None
697+
698+
# For conversion metrics
699+
entity: Optional[str] = None
700+
calculation: Optional[str] = None
701+
base_metric: Optional[Union[str, Dict[str, Any]]] = None
702+
conversion_metric: Optional[Union[str, Dict[str, Any]]] = None
703+
constant_properties: Optional[List[Dict[str, Any]]] = None
704+
628705
@classmethod
629706
def validate(cls, data):
630-
super(UnparsedMetric, cls).validate(data)
707+
super(UnparsedMetricV2, cls).validate(data)
631708
if "name" in data:
632709
errors = []
633710
if " " in data["name"]:
634711
errors.append("cannot contain spaces")
635-
# This handles failing queries due to too long metric names.
636-
# It only occurs in BigQuery and Snowflake (Postgres/Redshift truncate)
637712
if len(data["name"]) > 250:
638713
errors.append("cannot contain more than 250 characters")
639714
if not (re.match(r"^[A-Za-z]", data["name"])):
640715
errors.append("must begin with a letter")
641-
if not (re.match(r"[\w]+$", data["name"])):
716+
if not (re.match(r"[\w-]+$", data["name"])):
642717
errors.append("must contain only letters, numbers and underscores")
643-
644718
if errors:
645-
raise ValidationError(
719+
raise ParsingError(
646720
f"The metric name '{data['name']}' is invalid. It {', '.join(e for e in errors)}"
647721
)
648722

core/dbt/parser/schema_yaml_readers.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
UnparsedMetricInput,
5050
UnparsedMetricInputMeasure,
5151
UnparsedMetricTypeParams,
52+
UnparsedMetricV2,
5253
UnparsedNonAdditiveDimension,
5354
UnparsedQueryParams,
5455
UnparsedSavedQuery,
@@ -72,7 +73,7 @@
7273

7374

7475
def parse_where_filter(
75-
where: Optional[Union[List[str], str]]
76+
where: Optional[Union[List[str], str]],
7677
) -> Optional[WhereFilterIntersection]:
7778
if where is None:
7879
return None
@@ -470,12 +471,19 @@ def _generate_metric_config(
470471

471472
def parse(self) -> None:
472473
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)
474+
if "type_params" in data:
475+
try:
476+
UnparsedMetric.validate(data)
477+
unparsed = UnparsedMetric.from_dict(data)
478+
479+
except (ValidationError, JSONValidationError) as exc:
480+
raise YamlParseDictError(self.yaml.path, self.key, data, exc)
481+
else:
482+
try:
483+
UnparsedMetricV2.validate(data)
484+
unparsed = UnparsedMetricV2.from_dict(data)
485+
except (ValidationError, JSONValidationError) as exc:
486+
raise YamlParseDictError(self.yaml.path, self.key, data, exc)
479487
self.parse_metric(unparsed)
480488

481489

tests/unit/contracts/graph/test_unparsed.py

Lines changed: 91 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import pickle
2+
from abc import abstractmethod
23
from datetime import timedelta
4+
from typing import Any, Dict
35

46
import pytest
7+
from typing_extensions import override
58

69
from dbt.artifacts.resources import (
710
ExposureType,
@@ -883,9 +886,62 @@ def test_bad_tags(self):
883886
self.assert_fails_validation(tst)
884887

885888

886-
class TestUnparsedMetric(ContractTestCase):
889+
class BaseTestUnparsedMetric:
890+
891+
@abstractmethod
892+
def get_ok_dict(self) -> Dict[str, Any]:
893+
raise NotImplementedError()
894+
895+
def test_bad_metric_no_type_params(self):
896+
tst = self.get_ok_dict()
897+
del tst["type_params"]
898+
self.assert_fails_validation(tst)
899+
900+
def test_bad_tags(self):
901+
tst = self.get_ok_dict()
902+
tst["tags"] = [123]
903+
self.assert_fails_validation(tst)
904+
905+
def test_bad_metric_name_with_spaces(self):
906+
tst = self.get_ok_dict()
907+
tst["name"] = "metric name with spaces"
908+
self.assert_fails_validation(tst)
909+
910+
def test_bad_metric_name_too_long(self):
911+
tst = self.get_ok_dict()
912+
tst["name"] = "a" * 251
913+
self.assert_fails_validation(tst)
914+
915+
def test_bad_metric_name_does_not_start_with_letter(self):
916+
tst = self.get_ok_dict()
917+
tst["name"] = "123metric"
918+
self.assert_fails_validation(tst)
919+
920+
tst["name"] = "_metric"
921+
self.assert_fails_validation(tst)
922+
923+
def test_bad_metric_name_contains_special_characters(self):
924+
tst = self.get_ok_dict()
925+
tst["name"] = "metric!name"
926+
self.assert_fails_validation(tst)
927+
928+
tst["name"] = "metric@name"
929+
self.assert_fails_validation(tst)
930+
931+
tst["name"] = "metric#name"
932+
self.assert_fails_validation(tst)
933+
934+
tst["name"] = "metric$name"
935+
self.assert_fails_validation(tst)
936+
937+
tst["name"] = "metric-name"
938+
self.assert_fails_validation(tst)
939+
940+
941+
class TestUnparsedMetric(BaseTestUnparsedMetric, ContractTestCase):
887942
ContractType = UnparsedMetric
888943

944+
@override
889945
def get_ok_dict(self):
890946
return {
891947
"name": "new_customers",
@@ -923,50 +979,50 @@ def test_ok(self):
923979
self.assert_symmetric(metric, dct)
924980
pickle.loads(pickle.dumps(metric))
925981

926-
def test_bad_metric_no_type_params(self):
927-
tst = self.get_ok_dict()
928-
del tst["type_params"]
929-
self.assert_fails_validation(tst)
982+
# def test_bad_metric_no_type_params(self):
983+
# tst = self.get_ok_dict()
984+
# del tst["type_params"]
985+
# self.assert_fails_validation(tst)
930986

931-
def test_bad_tags(self):
932-
tst = self.get_ok_dict()
933-
tst["tags"] = [123]
934-
self.assert_fails_validation(tst)
987+
# def test_bad_tags(self):
988+
# tst = self.get_ok_dict()
989+
# tst["tags"] = [123]
990+
# self.assert_fails_validation(tst)
935991

936-
def test_bad_metric_name_with_spaces(self):
937-
tst = self.get_ok_dict()
938-
tst["name"] = "metric name with spaces"
939-
self.assert_fails_validation(tst)
992+
# def test_bad_metric_name_with_spaces(self):
993+
# tst = self.get_ok_dict()
994+
# tst["name"] = "metric name with spaces"
995+
# self.assert_fails_validation(tst)
940996

941-
def test_bad_metric_name_too_long(self):
942-
tst = self.get_ok_dict()
943-
tst["name"] = "a" * 251
944-
self.assert_fails_validation(tst)
997+
# def test_bad_metric_name_too_long(self):
998+
# tst = self.get_ok_dict()
999+
# tst["name"] = "a" * 251
1000+
# self.assert_fails_validation(tst)
9451001

946-
def test_bad_metric_name_does_not_start_with_letter(self):
947-
tst = self.get_ok_dict()
948-
tst["name"] = "123metric"
949-
self.assert_fails_validation(tst)
1002+
# def test_bad_metric_name_does_not_start_with_letter(self):
1003+
# tst = self.get_ok_dict()
1004+
# tst["name"] = "123metric"
1005+
# self.assert_fails_validation(tst)
9501006

951-
tst["name"] = "_metric"
952-
self.assert_fails_validation(tst)
1007+
# tst["name"] = "_metric"
1008+
# self.assert_fails_validation(tst)
9531009

954-
def test_bad_metric_name_contains_special_characters(self):
955-
tst = self.get_ok_dict()
956-
tst["name"] = "metric!name"
957-
self.assert_fails_validation(tst)
1010+
# def test_bad_metric_name_contains_special_characters(self):
1011+
# tst = self.get_ok_dict()
1012+
# tst["name"] = "metric!name"
1013+
# self.assert_fails_validation(tst)
9581014

959-
tst["name"] = "metric@name"
960-
self.assert_fails_validation(tst)
1015+
# tst["name"] = "metric@name"
1016+
# self.assert_fails_validation(tst)
9611017

962-
tst["name"] = "metric#name"
963-
self.assert_fails_validation(tst)
1018+
# tst["name"] = "metric#name"
1019+
# self.assert_fails_validation(tst)
9641020

965-
tst["name"] = "metric$name"
966-
self.assert_fails_validation(tst)
1021+
# tst["name"] = "metric$name"
1022+
# self.assert_fails_validation(tst)
9671023

968-
tst["name"] = "metric-name"
969-
self.assert_fails_validation(tst)
1024+
# tst["name"] = "metric-name"
1025+
# self.assert_fails_validation(tst)
9701026

9711027

9721028
class TestUnparsedVersion(ContractTestCase):

0 commit comments

Comments
 (0)