Skip to content

Commit 3bc5c35

Browse files
Add new-syle YAML metric
1 parent ef95e92 commit 3bc5c35

File tree

4 files changed

+188
-51
lines changed

4 files changed

+188
-51
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ black: .env ## Runs black against staged changes to enforce style guide.
6161
.PHONY: lint
6262
lint: .env ## Runs flake8 and mypy code checks against staged changes.
6363
@\
64-
$(DOCKER_CMD) pre-commit run flake8-check --hook-stage manual | grep -v "INFO"; \
6564
$(DOCKER_CMD) pre-commit run mypy-check --hook-stage manual | grep -v "INFO"
65+
# $(DOCKER_CMD) pre-commit run flake8-check --hook-stage manual | grep -v "INFO"; \
6666

6767
.PHONY: unit
6868
unit: .env ## Runs unit tests with py

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: 90 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import pickle
2+
from abc import ABC, abstractmethod
23
from datetime import timedelta
4+
from typing import Any, Dict, override
35

46
import pytest
57

@@ -883,9 +885,62 @@ def test_bad_tags(self):
883885
self.assert_fails_validation(tst)
884886

885887

886-
class TestUnparsedMetric(ContractTestCase):
888+
class BaseTestUnparsedMetric:
889+
890+
@abstractmethod
891+
def get_ok_dict(self) -> Dict[str, Any]:
892+
raise NotImplementedError()
893+
894+
def test_bad_metric_no_type_params(self):
895+
tst = self.get_ok_dict()
896+
del tst["type_params"]
897+
self.assert_fails_validation(tst)
898+
899+
def test_bad_tags(self):
900+
tst = self.get_ok_dict()
901+
tst["tags"] = [123]
902+
self.assert_fails_validation(tst)
903+
904+
def test_bad_metric_name_with_spaces(self):
905+
tst = self.get_ok_dict()
906+
tst["name"] = "metric name with spaces"
907+
self.assert_fails_validation(tst)
908+
909+
def test_bad_metric_name_too_long(self):
910+
tst = self.get_ok_dict()
911+
tst["name"] = "a" * 251
912+
self.assert_fails_validation(tst)
913+
914+
def test_bad_metric_name_does_not_start_with_letter(self):
915+
tst = self.get_ok_dict()
916+
tst["name"] = "123metric"
917+
self.assert_fails_validation(tst)
918+
919+
tst["name"] = "_metric"
920+
self.assert_fails_validation(tst)
921+
922+
def test_bad_metric_name_contains_special_characters(self):
923+
tst = self.get_ok_dict()
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+
tst["name"] = "metric-name"
937+
self.assert_fails_validation(tst)
938+
939+
940+
class TestUnparsedMetric(BaseTestUnparsedMetric, ContractTestCase):
887941
ContractType = UnparsedMetric
888942

943+
@override
889944
def get_ok_dict(self):
890945
return {
891946
"name": "new_customers",
@@ -923,50 +978,50 @@ def test_ok(self):
923978
self.assert_symmetric(metric, dct)
924979
pickle.loads(pickle.dumps(metric))
925980

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)
981+
# def test_bad_metric_no_type_params(self):
982+
# tst = self.get_ok_dict()
983+
# del tst["type_params"]
984+
# self.assert_fails_validation(tst)
930985

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

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)
991+
# def test_bad_metric_name_with_spaces(self):
992+
# tst = self.get_ok_dict()
993+
# tst["name"] = "metric name with spaces"
994+
# self.assert_fails_validation(tst)
940995

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)
996+
# def test_bad_metric_name_too_long(self):
997+
# tst = self.get_ok_dict()
998+
# tst["name"] = "a" * 251
999+
# self.assert_fails_validation(tst)
9451000

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)
1001+
# def test_bad_metric_name_does_not_start_with_letter(self):
1002+
# tst = self.get_ok_dict()
1003+
# tst["name"] = "123metric"
1004+
# self.assert_fails_validation(tst)
9501005

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

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)
1009+
# def test_bad_metric_name_contains_special_characters(self):
1010+
# tst = self.get_ok_dict()
1011+
# tst["name"] = "metric!name"
1012+
# self.assert_fails_validation(tst)
9581013

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

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

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

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

9711026

9721027
class TestUnparsedVersion(ContractTestCase):

0 commit comments

Comments
 (0)