Skip to content

Commit 8930660

Browse files
Add new-syle YAML unparsed metric
1 parent 77e1c7d commit 8930660

File tree

2 files changed

+168
-45
lines changed

2 files changed

+168
-45
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

tests/unit/contracts/graph/test_unparsed.py

Lines changed: 86 additions & 37 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,
@@ -23,6 +26,7 @@
2326
UnparsedMetric,
2427
UnparsedMetricInputMeasure,
2528
UnparsedMetricTypeParams,
29+
UnparsedMetricV2,
2630
UnparsedModelUpdate,
2731
UnparsedNode,
2832
UnparsedNodeUpdate,
@@ -883,9 +887,57 @@ def test_bad_tags(self):
883887
self.assert_fails_validation(tst)
884888

885889

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

940+
@override
889941
def get_ok_dict(self):
890942
return {
891943
"name": "new_customers",
@@ -928,45 +980,42 @@ def test_bad_metric_no_type_params(self):
928980
del tst["type_params"]
929981
self.assert_fails_validation(tst)
930982

931-
def test_bad_tags(self):
932-
tst = self.get_ok_dict()
933-
tst["tags"] = [123]
934-
self.assert_fails_validation(tst)
935-
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)
940-
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)
945983

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)
950-
951-
tst["name"] = "_metric"
952-
self.assert_fails_validation(tst)
953-
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)
958-
959-
tst["name"] = "metric@name"
960-
self.assert_fails_validation(tst)
961-
962-
tst["name"] = "metric#name"
963-
self.assert_fails_validation(tst)
984+
class TestUnparsedMetricV2(BaseTestUnparsedMetric, ContractTestCase):
985+
ContractType = UnparsedMetricV2
964986

965-
tst["name"] = "metric$name"
966-
self.assert_fails_validation(tst)
987+
@override
988+
def get_ok_dict(self):
989+
return {
990+
"name": "new_customers",
991+
"label": "New Customers",
992+
"description": "New customers",
993+
"type": "simple",
994+
"agg": "sum",
995+
"filter": "is_new = true",
996+
"join_to_timespine": False,
997+
"config": {},
998+
"tags": [],
999+
"meta": {"is_okr": True},
1000+
}
9671001

968-
tst["name"] = "metric-name"
969-
self.assert_fails_validation(tst)
1002+
def test_ok(self):
1003+
metric = self.ContractType(
1004+
name="new_customers",
1005+
label="New Customers",
1006+
description="New customers",
1007+
agg="sum",
1008+
filter="is_new = true",
1009+
join_to_timespine=False,
1010+
config={},
1011+
tags=[],
1012+
meta={"is_okr": True},
1013+
)
1014+
dct = self.get_ok_dict()
1015+
# add defaults:
1016+
dct["hidden"] = False
1017+
self.assert_symmetric(metric, dct)
1018+
pickle.loads(pickle.dumps(metric))
9701019

9711020

9721021
class TestUnparsedVersion(ContractTestCase):

0 commit comments

Comments
 (0)