diff --git a/.changes/unreleased/Features-20231127-124601.yaml b/.changes/unreleased/Features-20231127-124601.yaml new file mode 100644 index 00000000..69ce1b99 --- /dev/null +++ b/.changes/unreleased/Features-20231127-124601.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Added spec changes for conversion metrics. +time: 2023-11-27T12:46:01.036548-05:00 +custom: + Author: WilliamDee + Issue: "210" diff --git a/.changes/unreleased/Features-20231127-150021.yaml b/.changes/unreleased/Features-20231127-150021.yaml new file mode 100644 index 00000000..a957f293 --- /dev/null +++ b/.changes/unreleased/Features-20231127-150021.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Added validation for conversion metric configurations. +time: 2023-11-27T15:00:21.734245-05:00 +custom: + Author: WilliamDee + Issue: "211" diff --git a/dbt_semantic_interfaces/implementations/metric.py b/dbt_semantic_interfaces/implementations/metric.py index 656e09c5..366648a4 100644 --- a/dbt_semantic_interfaces/implementations/metric.py +++ b/dbt_semantic_interfaces/implementations/metric.py @@ -17,7 +17,11 @@ ) from dbt_semantic_interfaces.implementations.metadata import PydanticMetadata from dbt_semantic_interfaces.references import MeasureReference, MetricReference -from dbt_semantic_interfaces.type_enums import MetricType, TimeGranularity +from dbt_semantic_interfaces.type_enums import ( + ConversionCalculationType, + MetricType, + TimeGranularity, +) class PydanticMetricInputMeasure(PydanticCustomInputParser, HashableBaseModel): @@ -114,6 +118,13 @@ def parse(window: str) -> PydanticMetricTimeWindow: ) +class PydanticConstantPropertyInput(HashableBaseModel): + """Input of a constant property used in conversion metrics.""" + + base_property: str + conversion_property: str + + class PydanticMetricInput(HashableBaseModel): """Provides a pointer to a metric along with the additional properties used on that metric.""" @@ -134,6 +145,17 @@ def post_aggregation_reference(self) -> MetricReference: return MetricReference(element_name=self.alias or self.name) +class PydanticConversionTypeParams(HashableBaseModel): + """Type params to provide context for conversion metrics properties.""" + + base_measure: PydanticMetricInputMeasure + conversion_measure: PydanticMetricInputMeasure + entity: str + calculation: ConversionCalculationType = ConversionCalculationType.CONVERSION_RATE + window: Optional[PydanticMetricTimeWindow] + constant_properties: Optional[List[PydanticConstantPropertyInput]] + + class PydanticMetricTypeParams(HashableBaseModel): """Type params add additional context to certain metric types (the context depends on the metric type).""" @@ -144,6 +166,7 @@ class PydanticMetricTypeParams(HashableBaseModel): window: Optional[PydanticMetricTimeWindow] grain_to_date: Optional[TimeGranularity] metrics: Optional[List[PydanticMetricInput]] + conversion_type_params: Optional[PydanticConversionTypeParams] input_measures: List[PydanticMetricInputMeasure] = Field(default_factory=list) @@ -172,7 +195,7 @@ def measure_references(self) -> List[MeasureReference]: @property def input_metrics(self) -> Sequence[PydanticMetricInput]: """Return the associated input metrics for this metric.""" - if self.type is MetricType.SIMPLE or self.type is MetricType.CUMULATIVE: + if self.type is MetricType.SIMPLE or self.type is MetricType.CUMULATIVE or self.type is MetricType.CONVERSION: return () elif self.type is MetricType.DERIVED: assert self.type_params.metrics is not None, f"{MetricType.DERIVED} should have type_params.metrics set" diff --git a/dbt_semantic_interfaces/parsing/generated_json_schemas/default_explicit_schema.json b/dbt_semantic_interfaces/parsing/generated_json_schemas/default_explicit_schema.json index 7384be5f..b023f370 100644 --- a/dbt_semantic_interfaces/parsing/generated_json_schemas/default_explicit_schema.json +++ b/dbt_semantic_interfaces/parsing/generated_json_schemas/default_explicit_schema.json @@ -17,6 +17,61 @@ }, "type": "object" }, + "constant_property_input_schema": { + "$id": "constant_property_input_schema", + "additionalProperties": false, + "properties": { + "base_property": { + "type": "string" + }, + "conversion_property": { + "type": "string" + } + }, + "required": [ + "base_property", + "conversion_property" + ], + "type": "object" + }, + "conversion_type_params_schema": { + "$id": "conversion_type_params_schema", + "additionalProperties": false, + "properties": { + "base_measure": { + "$ref": "#/definitions/metric_input_measure_schema" + }, + "calculation": { + "enum": [ + "CONVERSIONS", + "CONVERSION_RATE", + "conversions", + "conversion_rate" + ] + }, + "constant_properties": { + "items": { + "$ref": "#/definitions/constant_property_input_schema" + }, + "type": "array" + }, + "conversion_measure": { + "$ref": "#/definitions/metric_input_measure_schema" + }, + "entity": { + "type": "string" + }, + "window": { + "type": "string" + } + }, + "required": [ + "base_measure", + "conversion_measure", + "entity" + ], + "type": "object" + }, "dimension_schema": { "$id": "dimension_schema", "additionalProperties": false, @@ -347,10 +402,12 @@ "RATIO", "CUMULATIVE", "DERIVED", + "CONVERSION", "simple", "ratio", "cumulative", - "derived" + "derived", + "conversion" ] }, "type_params": { @@ -368,6 +425,9 @@ "$id": "metric_type_params", "additionalProperties": false, "properties": { + "conversion_type_params": { + "$ref": "#/definitions/conversion_type_params_schema" + }, "denominator": { "$ref": "#/definitions/metric_input_measure_schema" }, diff --git a/dbt_semantic_interfaces/parsing/schemas.py b/dbt_semantic_interfaces/parsing/schemas.py index aafdc4b4..5636a7db 100644 --- a/dbt_semantic_interfaces/parsing/schemas.py +++ b/dbt_semantic_interfaces/parsing/schemas.py @@ -9,9 +9,12 @@ # Enums -metric_types_enum_values = ["SIMPLE", "RATIO", "CUMULATIVE", "DERIVED"] +metric_types_enum_values = ["SIMPLE", "RATIO", "CUMULATIVE", "DERIVED", "CONVERSION"] metric_types_enum_values += [x.lower() for x in metric_types_enum_values] +calculation_types_enum_values = ["CONVERSIONS", "CONVERSION_RATE"] +calculation_types_enum_values += [x.lower() for x in calculation_types_enum_values] + entity_type_enum_values = ["PRIMARY", "UNIQUE", "FOREIGN", "NATURAL"] entity_type_enum_values += [x.lower() for x in entity_type_enum_values] @@ -85,6 +88,32 @@ "additionalProperties": False, } +conversion_type_params_schema = { + "$id": "conversion_type_params_schema", + "type": "object", + "properties": { + "base_measure": {"$ref": "metric_input_measure_schema"}, + "conversion_measure": {"$ref": "metric_input_measure_schema"}, + "calculation": {"enum": calculation_types_enum_values}, + "entity": {"type": "string"}, + "window": {"type": "string"}, + "constant_properties": {"type": "array", "items": {"$ref": "constant_property_input_schema"}}, + }, + "additionalProperties": False, + "required": ["base_measure", "conversion_measure", "entity"], +} + +constant_property_input_schema = { + "$id": "constant_property_input_schema", + "type": "object", + "properties": { + "base_property": {"type": "string"}, + "conversion_property": {"type": "string"}, + }, + "additionalProperties": False, + "required": ["base_property", "conversion_property"], +} + metric_type_params_schema = { "$id": "metric_type_params", "type": "object", @@ -99,6 +128,7 @@ "type": "array", "items": {"$ref": "metric_input_schema"}, }, + "conversion_type_params": {"$ref": "conversion_type_params_schema"}, }, "additionalProperties": False, } @@ -382,6 +412,8 @@ filter_schema["$id"]: filter_schema, metric_input_measure_schema["$id"]: metric_input_measure_schema, metric_type_params_schema["$id"]: metric_type_params_schema, + conversion_type_params_schema["$id"]: conversion_type_params_schema, + constant_property_input_schema["$id"]: constant_property_input_schema, entity_schema["$id"]: entity_schema, measure_schema["$id"]: measure_schema, dimension_schema["$id"]: dimension_schema, diff --git a/dbt_semantic_interfaces/protocols/__init__.py b/dbt_semantic_interfaces/protocols/__init__.py index 239aa83b..290c6374 100644 --- a/dbt_semantic_interfaces/protocols/__init__.py +++ b/dbt_semantic_interfaces/protocols/__init__.py @@ -11,6 +11,8 @@ ) from dbt_semantic_interfaces.protocols.metadata import FileSlice, Metadata # noqa:F401 from dbt_semantic_interfaces.protocols.metric import ( # noqa:F401 + ConstantPropertyInput, + ConversionTypeParams, Metric, MetricInput, MetricInputMeasure, diff --git a/dbt_semantic_interfaces/protocols/metric.py b/dbt_semantic_interfaces/protocols/metric.py index 31f21682..66b48dd1 100644 --- a/dbt_semantic_interfaces/protocols/metric.py +++ b/dbt_semantic_interfaces/protocols/metric.py @@ -6,7 +6,11 @@ from dbt_semantic_interfaces.protocols.metadata import Metadata from dbt_semantic_interfaces.protocols.where_filter import WhereFilterIntersection from dbt_semantic_interfaces.references import MeasureReference, MetricReference -from dbt_semantic_interfaces.type_enums import MetricType, TimeGranularity +from dbt_semantic_interfaces.type_enums import ( + ConversionCalculationType, + MetricType, + TimeGranularity, +) class MetricInputMeasure(Protocol): @@ -113,6 +117,67 @@ def post_aggregation_reference(self) -> MetricReference: pass +class ConstantPropertyInput(Protocol): + """Provides the constant property set for conversion metrics. + + Constant properties are additional elements linking a base event to a conversion event. + The specified properties will typically be a reference to a dimension or entity, and will be used + to join the base event to the final conversion event. Typical constant properties are things like + session keys (for services where conversions are measured within a user session), or secondary entities + (like a user/application pair for an app platform or a user/shop pair for a retail/online storefront platform). + """ + + @property + @abstractmethod + def base_property(self) -> str: # noqa: D + pass + + @property + @abstractmethod + def conversion_property(self) -> str: # noqa: D + pass + + +class ConversionTypeParams(Protocol): + """Type params to provide context for conversion metrics properties.""" + + @property + @abstractmethod + def base_measure(self) -> MetricInputMeasure: + """Measure used to calculate the base event.""" + pass + + @property + @abstractmethod + def conversion_measure(self) -> MetricInputMeasure: + """Measure used to calculate the conversion event.""" + pass + + @property + @abstractmethod + def entity(self) -> str: + """Specified join entity.""" + pass + + @property + @abstractmethod + def calculation(self) -> ConversionCalculationType: + """Type of conversion metric calculation.""" + pass + + @property + @abstractmethod + def window(self) -> Optional[MetricTimeWindow]: + """Maximum time range for finding successive conversion events.""" + pass + + @property + @abstractmethod + def constant_properties(self) -> Optional[Sequence[ConstantPropertyInput]]: + """Return the list of defined constant properties.""" + pass + + class MetricTypeParams(Protocol): """Type params add additional context to certain metric types (the context depends on the metric type).""" @@ -157,6 +222,11 @@ def grain_to_date(self) -> Optional[TimeGranularity]: # noqa: D def metrics(self) -> Optional[Sequence[MetricInput]]: # noqa: D pass + @property + @abstractmethod + def conversion_type_params(self) -> Optional[ConversionTypeParams]: # noqa: D + pass + class Metric(Protocol): """Describes a metric.""" diff --git a/dbt_semantic_interfaces/transformations/add_input_metric_measures.py b/dbt_semantic_interfaces/transformations/add_input_metric_measures.py index c29bce8f..bd09a50e 100644 --- a/dbt_semantic_interfaces/transformations/add_input_metric_measures.py +++ b/dbt_semantic_interfaces/transformations/add_input_metric_measures.py @@ -42,6 +42,11 @@ def _get_measures_for_metric( measures.update( AddInputMetricMeasuresRule._get_measures_for_metric(semantic_manifest, input_metric.name) ) + elif matched_metric.type is MetricType.CONVERSION: + conversion_type_params = matched_metric.type_params.conversion_type_params + assert conversion_type_params, "Conversion metric should have conversion_type_params." + measures.add(conversion_type_params.base_measure) + measures.add(conversion_type_params.conversion_measure) else: assert_values_exhausted(matched_metric.type) else: diff --git a/dbt_semantic_interfaces/type_enums/__init__.py b/dbt_semantic_interfaces/type_enums/__init__.py index 68b50b85..0389d2c4 100644 --- a/dbt_semantic_interfaces/type_enums/__init__.py +++ b/dbt_semantic_interfaces/type_enums/__init__.py @@ -1,6 +1,9 @@ from dbt_semantic_interfaces.type_enums.aggregation_type import ( # noqa:F401 AggregationType, ) +from dbt_semantic_interfaces.type_enums.conversion_calculation_type import ( # noqa:F401 + ConversionCalculationType, +) from dbt_semantic_interfaces.type_enums.dimension_type import DimensionType # noqa:F401 from dbt_semantic_interfaces.type_enums.entity_type import EntityType # noqa:F401 from dbt_semantic_interfaces.type_enums.metric_type import MetricType # noqa:F401 diff --git a/dbt_semantic_interfaces/type_enums/conversion_calculation_type.py b/dbt_semantic_interfaces/type_enums/conversion_calculation_type.py new file mode 100644 index 00000000..c112128c --- /dev/null +++ b/dbt_semantic_interfaces/type_enums/conversion_calculation_type.py @@ -0,0 +1,8 @@ +from dbt_semantic_interfaces.enum_extension import ExtendedEnum + + +class ConversionCalculationType(ExtendedEnum): + """Types of calculations for a conversion metric.""" + + CONVERSIONS = "conversions" + CONVERSION_RATE = "conversion_rate" diff --git a/dbt_semantic_interfaces/type_enums/metric_type.py b/dbt_semantic_interfaces/type_enums/metric_type.py index 36286014..2b107e71 100644 --- a/dbt_semantic_interfaces/type_enums/metric_type.py +++ b/dbt_semantic_interfaces/type_enums/metric_type.py @@ -8,3 +8,4 @@ class MetricType(ExtendedEnum): RATIO = "ratio" CUMULATIVE = "cumulative" DERIVED = "derived" + CONVERSION = "conversion" diff --git a/dbt_semantic_interfaces/validations/metrics.py b/dbt_semantic_interfaces/validations/metrics.py index 6c213de9..4533a1df 100644 --- a/dbt_semantic_interfaces/validations/metrics.py +++ b/dbt_semantic_interfaces/validations/metrics.py @@ -1,15 +1,17 @@ import traceback -from typing import Generic, List, Sequence +from typing import Generic, List, Optional, Sequence from dbt_semantic_interfaces.errors import ParsingException from dbt_semantic_interfaces.implementations.metric import PydanticMetricTimeWindow from dbt_semantic_interfaces.protocols import ( + ConversionTypeParams, Metric, SemanticManifest, SemanticManifestT, + SemanticModel, ) -from dbt_semantic_interfaces.references import MetricModelReference -from dbt_semantic_interfaces.type_enums import MetricType +from dbt_semantic_interfaces.references import MeasureReference, MetricModelReference +from dbt_semantic_interfaces.type_enums import AggregationType, MetricType from dbt_semantic_interfaces.validations.unique_valid_name import UniqueAndValidNameRule from dbt_semantic_interfaces.validations.validator_helpers import ( FileContext, @@ -261,3 +263,211 @@ def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[Validati for metric in semantic_manifest.metrics or []: issues += WhereFiltersAreParseable._validate_metric(metric) return issues + + +class ConversionMetricRule(SemanticManifestValidationRule[SemanticManifestT], Generic[SemanticManifestT]): + """Checks that conversion metrics are configured properly.""" + + @staticmethod + @validate_safely(whats_being_done="checking that the params of metric are valid if it is a conversion metric") + def _validate_type_params(metric: Metric, conversion_type_params: ConversionTypeParams) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + + window = conversion_type_params.window + if window: + try: + window_str = f"{window.count} {window.granularity.value}" + PydanticMetricTimeWindow.parse(window_str) + except ParsingException as e: + issues.append( + ValidationError( + context=MetricContext( + file_context=FileContext.from_metadata(metadata=metric.metadata), + metric=MetricModelReference(metric_name=metric.name), + ), + message="".join(traceback.format_exception_only(type(e), value=e)), + extra_detail="".join(traceback.format_tb(e.__traceback__)), + ) + ) + return issues + + @staticmethod + @validate_safely(whats_being_done="checks that the entity exists in the base/conversion semantic model") + def _validate_entity_exists( + metric: Metric, entity: str, base_semantic_model: SemanticModel, conversion_semantic_model: SemanticModel + ) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + + if entity not in {entity.name for entity in base_semantic_model.entities}: + issues.append( + ValidationError( + context=MetricContext( + file_context=FileContext.from_metadata(metadata=metric.metadata), + metric=MetricModelReference(metric_name=metric.name), + ), + message=f"Entity: {entity} not found in base semantic model: {base_semantic_model.name}.", + ) + ) + if entity not in {entity.name for entity in conversion_semantic_model.entities}: + issues.append( + ValidationError( + context=MetricContext( + file_context=FileContext.from_metadata(metadata=metric.metadata), + metric=MetricModelReference(metric_name=metric.name), + ), + message=f"Entity: {entity} not found in " + f"conversion semantic model: {conversion_semantic_model.name}.", + ) + ) + return issues + + @staticmethod + @validate_safely(whats_being_done="checks that the provided measures are valid for conversion metrics") + def _validate_measures( + metric: Metric, base_semantic_model: SemanticModel, conversion_semantic_model: SemanticModel + ) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + + def _validate_measure(measure_reference: MeasureReference, semantic_model: SemanticModel) -> None: + measure = None + for model_measure in semantic_model.measures: + if model_measure.reference == measure_reference: + measure = model_measure + break + + assert measure, f"Measure '{model_measure.name}' wasn't found in semantic model '{semantic_model.name}'" + + if ( + measure.agg != AggregationType.COUNT + and measure.agg != AggregationType.COUNT_DISTINCT + and (measure.agg != AggregationType.SUM or measure.expr != "1") + ): + issues.append( + ValidationError( + context=MetricContext( + file_context=FileContext.from_metadata(metadata=metric.metadata), + metric=MetricModelReference(metric_name=metric.name), + ), + message=f"For conversion metrics, the measure must be COUNT/SUM(1)/COUNT_DISTINCT. " + f"Measure: {measure.name} is agg type: {measure.agg}", + ) + ) + + conversion_type_params = metric.type_params.conversion_type_params + assert ( + conversion_type_params is not None + ), "For a conversion metric, type_params.conversion_type_params must exist." + _validate_measure( + measure_reference=conversion_type_params.base_measure.measure_reference, + semantic_model=base_semantic_model, + ) + _validate_measure( + measure_reference=conversion_type_params.conversion_measure.measure_reference, + semantic_model=conversion_semantic_model, + ) + return issues + + @staticmethod + @validate_safely(whats_being_done="checks that the provided constant properties are valid") + def _validate_constant_properties( + metric: Metric, base_semantic_model: SemanticModel, conversion_semantic_model: SemanticModel + ) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + + def _elements_in_model(references: List[str], semantic_model: SemanticModel) -> None: + linkable_elements = [entity.name for entity in semantic_model.entities] + [ + dimension.name for dimension in semantic_model.dimensions + ] + for reference in references: + if reference not in linkable_elements: + issues.append( + ValidationError( + context=MetricContext( + file_context=FileContext.from_metadata(metadata=metric.metadata), + metric=MetricModelReference(metric_name=metric.name), + ), + message=f"The provided constant property: {reference}, " + f"cannot be found in semantic model {semantic_model.name}", + ) + ) + + conversion_type_params = metric.type_params.conversion_type_params + assert ( + conversion_type_params is not None + ), "For a conversion metric, type_params.conversion_type_params must exist." + constant_properties = conversion_type_params.constant_properties or [] + base_properties = [] + conversion_properties = [] + for constant_property in constant_properties: + base_properties.append(constant_property.base_property) + conversion_properties.append(constant_property.conversion_property) + + _elements_in_model(references=base_properties, semantic_model=base_semantic_model) + _elements_in_model(references=conversion_properties, semantic_model=conversion_semantic_model) + return issues + + @staticmethod + def _get_semantic_model_from_measure( + measure_reference: MeasureReference, semantic_manifest: SemanticManifest + ) -> Optional[SemanticModel]: + """Retrieve the semantic model from a given measure reference.""" + semantic_model = None + for model in semantic_manifest.semantic_models: + if measure_reference in {measure.reference for measure in model.measures}: + semantic_model = model + break + return semantic_model + + @staticmethod + @validate_safely(whats_being_done="running manifest validation ensuring conversion metrics are valid") + def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[ValidationIssue]: # noqa: D + issues: List[ValidationIssue] = [] + + for metric in semantic_manifest.metrics or []: + if metric.type == MetricType.CONVERSION: + # Validates that the measure exists and corresponds to a semantic model + assert ( + metric.type_params.conversion_type_params is not None + ), "For a conversion metric, type_params.conversion_type_params must exist." + + base_semantic_model = ConversionMetricRule._get_semantic_model_from_measure( + measure_reference=metric.type_params.conversion_type_params.base_measure.measure_reference, + semantic_manifest=semantic_manifest, + ) + conversion_semantic_model = ConversionMetricRule._get_semantic_model_from_measure( + measure_reference=metric.type_params.conversion_type_params.conversion_measure.measure_reference, + semantic_manifest=semantic_manifest, + ) + if base_semantic_model is None or conversion_semantic_model is None: + # If measure's don't exist, stop this metric's validation as it will fail later validations + issues.append( + ValidationError( + context=MetricContext( + file_context=FileContext.from_metadata(metadata=metric.metadata), + metric=MetricModelReference(metric_name=metric.name), + ), + message=f"For metric '{metric.name}', conversion measures specified was not found.", + ) + ) + continue + + issues += ConversionMetricRule._validate_entity_exists( + metric=metric, + entity=metric.type_params.conversion_type_params.entity, + base_semantic_model=base_semantic_model, + conversion_semantic_model=conversion_semantic_model, + ) + issues += ConversionMetricRule._validate_measures( + metric=metric, + base_semantic_model=base_semantic_model, + conversion_semantic_model=conversion_semantic_model, + ) + issues += ConversionMetricRule._validate_type_params( + metric=metric, conversion_type_params=metric.type_params.conversion_type_params + ) + issues += ConversionMetricRule._validate_constant_properties( + metric=metric, + base_semantic_model=base_semantic_model, + conversion_semantic_model=conversion_semantic_model, + ) + return issues diff --git a/dbt_semantic_interfaces/validations/semantic_manifest_validator.py b/dbt_semantic_interfaces/validations/semantic_manifest_validator.py index 2a7de2ea..2093a9bc 100644 --- a/dbt_semantic_interfaces/validations/semantic_manifest_validator.py +++ b/dbt_semantic_interfaces/validations/semantic_manifest_validator.py @@ -24,6 +24,7 @@ SemanticModelMeasuresUniqueRule, ) from dbt_semantic_interfaces.validations.metrics import ( + ConversionMetricRule, CumulativeMetricRule, DerivedMetricRule, WhereFiltersAreParseable, @@ -89,6 +90,7 @@ class SemanticManifestValidator(Generic[SemanticManifestT]): MetricLabelsRule[SemanticManifestT](), SemanticModelLabelsRule[SemanticManifestT](), EntityLabelsRule[SemanticManifestT](), + ConversionMetricRule[SemanticManifestT](), ) def __init__( diff --git a/tests/parsing/test_metric_parsing.py b/tests/parsing/test_metric_parsing.py index dba46209..7e71cb9f 100644 --- a/tests/parsing/test_metric_parsing.py +++ b/tests/parsing/test_metric_parsing.py @@ -13,7 +13,11 @@ parse_yaml_files_to_semantic_manifest, ) from dbt_semantic_interfaces.parsing.objects import YamlConfigFile -from dbt_semantic_interfaces.type_enums import MetricType, TimeGranularity +from dbt_semantic_interfaces.type_enums import ( + ConversionCalculationType, + MetricType, + TimeGranularity, +) from dbt_semantic_interfaces.validations.validator_helpers import ( SemanticManifestValidationException, ) @@ -410,6 +414,84 @@ def test_derived_metric_input_parsing() -> None: ) +def test_conversion_metric_parsing() -> None: + """Test for parsing a conversion metric.""" + yaml_contents = textwrap.dedent( + """\ + metric: + name: conversion_metric + type: conversion + type_params: + conversion_type_params: + base_measure: opportunity + conversion_measure: conversions + window: 7 days + entity: user + """ + ) + file = YamlConfigFile(filepath="inline_for_test", contents=yaml_contents) + + build_result = parse_yaml_files_to_semantic_manifest(files=[file, EXAMPLE_PROJECT_CONFIGURATION_YAML_CONFIG_FILE]) + + assert len(build_result.semantic_manifest.metrics) == 1 + metric = build_result.semantic_manifest.metrics[0] + assert metric.name == "conversion_metric" + assert metric.type is MetricType.CONVERSION + assert metric.type_params + assert metric.type_params.conversion_type_params + assert metric.type_params.conversion_type_params.base_measure == PydanticMetricInputMeasure(name="opportunity") + assert metric.type_params.conversion_type_params.conversion_measure == PydanticMetricInputMeasure( + name="conversions" + ) + assert metric.type_params.conversion_type_params.window == PydanticMetricTimeWindow( + count=7, granularity=TimeGranularity.DAY + ) + assert metric.type_params.conversion_type_params.entity == "user" + assert metric.type_params.conversion_type_params.calculation == ConversionCalculationType.CONVERSION_RATE + + +def test_conversion_metric_parsing_with_constant_properties() -> None: + """Test for parsing a conversion metric with specified constant properties.""" + yaml_contents = textwrap.dedent( + """\ + metric: + name: conversion_metric + type: conversion + type_params: + conversion_type_params: + base_measure: opportunity + conversion_measure: conversions + entity: user + calculation: conversions + constant_properties: + - base_property: base_session_id + conversion_property: conversion_session_id + """ + ) + file = YamlConfigFile(filepath="inline_for_test", contents=yaml_contents) + + build_result = parse_yaml_files_to_semantic_manifest(files=[file, EXAMPLE_PROJECT_CONFIGURATION_YAML_CONFIG_FILE]) + + assert len(build_result.semantic_manifest.metrics) == 1 + metric = build_result.semantic_manifest.metrics[0] + assert metric.name == "conversion_metric" + assert metric.type is MetricType.CONVERSION + assert metric.type_params + assert metric.type_params.conversion_type_params + assert metric.type_params.conversion_type_params.base_measure == PydanticMetricInputMeasure(name="opportunity") + assert metric.type_params.conversion_type_params.conversion_measure == PydanticMetricInputMeasure( + name="conversions" + ) + assert metric.type_params.conversion_type_params.window is None + assert metric.type_params.conversion_type_params.entity == "user" + assert metric.type_params.conversion_type_params.calculation == ConversionCalculationType.CONVERSIONS + assert metric.type_params.conversion_type_params.constant_properties + assert metric.type_params.conversion_type_params.constant_properties[0].base_property == "base_session_id" + assert ( + metric.type_params.conversion_type_params.constant_properties[0].conversion_property == "conversion_session_id" + ) + + def test_invalid_metric_type_parsing_error() -> None: """Test for error detection when parsing a metric specification with an invalid MetricType input value.""" yaml_contents = textwrap.dedent( diff --git a/tests/test_implements_satisfy_protocols.py b/tests/test_implements_satisfy_protocols.py index c6d0fcd5..03fb16ef 100644 --- a/tests/test_implements_satisfy_protocols.py +++ b/tests/test_implements_satisfy_protocols.py @@ -20,6 +20,7 @@ ) from dbt_semantic_interfaces.implementations.metadata import PydanticMetadata from dbt_semantic_interfaces.implementations.metric import ( + PydanticConversionTypeParams, PydanticMetric, PydanticMetricInput, PydanticMetricInputMeasure, @@ -200,6 +201,26 @@ def test_metric_protocol_derived(metric: PydanticMetric) -> None: # noqa: D assert isinstance(metric, RuntimeCheckableMetric) +@given( + builds( + PydanticMetric, + type=just(MetricType.CONVERSION), + type_params=builds( + PydanticMetricTypeParams, + conversion_type_params=builds( + PydanticConversionTypeParams, + base_measure=builds(PydanticMetricInputMeasure), + conversion_measure=builds(PydanticMetricInputMeasure), + entity=builds(str), + ), + ), + expr=builds(str), + ) +) +def test_metric_protocol_conversion(metric: PydanticMetric) -> None: # noqa: D + assert isinstance(metric, RuntimeCheckableMetric) + + @runtime_checkable class RuntimeCheckableEntity(EntityProtocol, Protocol): """We don't want runtime_checkable versions of protocols in the package, but we want them for tests.""" diff --git a/tests/validations/test_metrics.py b/tests/validations/test_metrics.py index 9b8fbedf..da6bbf70 100644 --- a/tests/validations/test_metrics.py +++ b/tests/validations/test_metrics.py @@ -13,6 +13,8 @@ PydanticWhereFilterIntersection, ) from dbt_semantic_interfaces.implementations.metric import ( + PydanticConstantPropertyInput, + PydanticConversionTypeParams, PydanticMetricInput, PydanticMetricInputMeasure, PydanticMetricTimeWindow, @@ -39,6 +41,7 @@ TimeGranularity, ) from dbt_semantic_interfaces.validations.metrics import ( + ConversionMetricRule, DerivedMetricRule, WhereFiltersAreParseable, ) @@ -421,3 +424,128 @@ def test_where_filter_validations_bad_input_metric_filter( # noqa: D match=f"trying to parse filter for input metric `{input_metric.name}` on metric `{metric.name}`", ): validator.checked_validations(manifest) + + +def test_conversion_metrics() -> None: # noqa: D + base_measure_name = "base_measure" + conversion_measure_name = "conversion_measure" + entity = "entity" + invalid_entity = "bad" + invalid_measure = "invalid_measure" + window = PydanticMetricTimeWindow.parse("7 days") + validator = SemanticManifestValidator[PydanticSemanticManifest]([ConversionMetricRule()]) + result = validator.validate_semantic_manifest( + PydanticSemanticManifest( + semantic_models=[ + semantic_model_with_guaranteed_meta( + name="base", + measures=[ + PydanticMeasure( + name=base_measure_name, agg=AggregationType.COUNT, agg_time_dimension="ds", expr="1" + ), + PydanticMeasure(name=invalid_measure, agg=AggregationType.MAX, agg_time_dimension="ds"), + ], + dimensions=[ + PydanticDimension( + name="ds", + type=DimensionType.TIME, + type_params=PydanticDimensionTypeParams( + time_granularity=TimeGranularity.DAY, + ), + ), + ], + entities=[ + PydanticEntity(name=entity, type=EntityType.PRIMARY), + ], + ), + semantic_model_with_guaranteed_meta( + name="conversion", + measures=[ + PydanticMeasure( + name=conversion_measure_name, agg=AggregationType.COUNT, agg_time_dimension="ds", expr="1" + ) + ], + dimensions=[ + PydanticDimension( + name="ds", + type=DimensionType.TIME, + type_params=PydanticDimensionTypeParams( + time_granularity=TimeGranularity.DAY, + ), + ), + ], + entities=[ + PydanticEntity(name=entity, type=EntityType.PRIMARY), + ], + ), + ], + metrics=[ + metric_with_guaranteed_meta( + name="proper_metric", + type=MetricType.CONVERSION, + type_params=PydanticMetricTypeParams( + conversion_type_params=PydanticConversionTypeParams( + base_measure=PydanticMetricInputMeasure(name=base_measure_name), + conversion_measure=PydanticMetricInputMeasure(name=conversion_measure_name), + window=window, + entity=entity, + ) + ), + ), + metric_with_guaranteed_meta( + name="bad_measure_metric", + type=MetricType.CONVERSION, + type_params=PydanticMetricTypeParams( + conversion_type_params=PydanticConversionTypeParams( + base_measure=PydanticMetricInputMeasure(name=invalid_measure), + conversion_measure=PydanticMetricInputMeasure(name=conversion_measure_name), + window=window, + entity=entity, + ) + ), + ), + metric_with_guaranteed_meta( + name="entity_doesnt_exist", + type=MetricType.CONVERSION, + type_params=PydanticMetricTypeParams( + conversion_type_params=PydanticConversionTypeParams( + base_measure=PydanticMetricInputMeasure(name=base_measure_name), + conversion_measure=PydanticMetricInputMeasure(name=conversion_measure_name), + window=window, + entity=invalid_entity, + ) + ), + ), + metric_with_guaranteed_meta( + name="constant_property_doesnt_exist", + type=MetricType.CONVERSION, + type_params=PydanticMetricTypeParams( + conversion_type_params=PydanticConversionTypeParams( + base_measure=PydanticMetricInputMeasure(name=base_measure_name), + conversion_measure=PydanticMetricInputMeasure(name=conversion_measure_name), + window=window, + entity=entity, + constant_properties=[ + PydanticConstantPropertyInput(base_property="bad_dim", conversion_property="bad_dim2") + ], + ) + ), + ), + ], + project_configuration=EXAMPLE_PROJECT_CONFIGURATION, + ) + ) + + build_issues = result.errors + assert len(build_issues) == 5 + expected_substr1 = f"{invalid_entity} not found in base semantic model" + expected_substr2 = f"{invalid_entity} not found in conversion semantic model" + expected_substr3 = "the measure must be COUNT/SUM(1)/COUNT_DISTINCT" + expected_substr4 = "The provided constant property: bad_dim, cannot be found" + expected_substr5 = "The provided constant property: bad_dim2, cannot be found" + missing_error_strings = set() + for expected_str in [expected_substr1, expected_substr2, expected_substr3, expected_substr4, expected_substr5]: + if not any(actual_str.as_readable_str().find(expected_str) != -1 for actual_str in build_issues): + missing_error_strings.add(expected_str) + assert len(missing_error_strings) == 0, "Failed to match one or more expected errors: " + f"{missing_error_strings} in {set([x.as_readable_str() for x in build_issues])}"