Skip to content

Commit c8778fa

Browse files
Add new-syle YAML unparsed metric
1 parent 5dd5166 commit c8778fa

File tree

3 files changed

+164
-53
lines changed

3 files changed

+164
-53
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Features
2+
body: Add UnparsedMetricV2 to read in new-style YAML Semantic Layer Metrics.
3+
time: 2025-11-11T10:35:04.123144-08:00
4+
custom:
5+
Author: theyostalservice
6+
Issue: na

core/dbt/contracts/graph/unparsed.py

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -610,24 +610,10 @@ class UnparsedMetricTypeParams(dbtClassMixin):
610610
cumulative_type_params: Optional[UnparsedCumulativeTypeParams] = None
611611

612612

613-
@dataclass
614-
class UnparsedMetric(dbtClassMixin):
615-
name: str
616-
label: str
617-
type: str
618-
type_params: UnparsedMetricTypeParams
619-
description: str = ""
620-
# Note: `Union` must be the outermost part of the type annotation for serialization to work properly.
621-
filter: Union[str, List[str], None] = None
622-
time_granularity: Optional[str] = None
623-
# metadata: Optional[Unparsedetadata] = None # TODO
624-
meta: Dict[str, Any] = field(default_factory=dict)
625-
tags: List[str] = field(default_factory=list)
626-
config: Dict[str, Any] = field(default_factory=dict)
627-
613+
class UnparsedMetricBase(dbtClassMixin):
628614
@classmethod
629615
def validate(cls, data):
630-
super(UnparsedMetric, cls).validate(data)
616+
super().validate(data)
631617
if "name" in data:
632618
errors = []
633619
if " " in data["name"]:
@@ -647,6 +633,76 @@ def validate(cls, data):
647633
)
648634

649635

636+
@dataclass
637+
class UnparsedMetric(UnparsedMetricBase):
638+
"""Old-style YAML metric; prefer UnparsedMetricV2 instead as of late 2025."""
639+
640+
name: str
641+
label: str
642+
type: str
643+
type_params: UnparsedMetricTypeParams # old-style YAML
644+
description: str = ""
645+
# Note: `Union` must be the outermost part of the type annotation for serialization to work properly.
646+
filter: Union[str, List[str], None] = None
647+
time_granularity: Optional[str] = None
648+
# metadata: Optional[Unparsedetadata] = None # TODO
649+
meta: Dict[str, Any] = field(default_factory=dict)
650+
tags: List[str] = field(default_factory=list)
651+
config: Dict[str, Any] = field(default_factory=dict)
652+
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+
705+
650706
@dataclass
651707
class UnparsedGroup(dbtClassMixin):
652708
name: str

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)