diff --git a/.changes/unreleased/Features-20230926-173230.yaml b/.changes/unreleased/Features-20230926-173230.yaml new file mode 100644 index 00000000..e9728c60 --- /dev/null +++ b/.changes/unreleased/Features-20230926-173230.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Begin supporting `label` attr on top level objects and semantic model elements +time: 2023-09-26T17:32:30.099904-07:00 +custom: + Author: QMalcolm + Issue: "143" diff --git a/dbt_semantic_interfaces/implementations/elements/dimension.py b/dbt_semantic_interfaces/implementations/elements/dimension.py index 3d1a388e..c3cfa2e4 100644 --- a/dbt_semantic_interfaces/implementations/elements/dimension.py +++ b/dbt_semantic_interfaces/implementations/elements/dimension.py @@ -47,6 +47,7 @@ class PydanticDimension(HashableBaseModel, ModelWithMetadataParsing): type_params: Optional[PydanticDimensionTypeParams] expr: Optional[str] = None metadata: Optional[PydanticMetadata] + label: Optional[str] = None @property def reference(self) -> DimensionReference: # noqa: D diff --git a/dbt_semantic_interfaces/implementations/elements/entity.py b/dbt_semantic_interfaces/implementations/elements/entity.py index 5928ae62..ac98e4db 100644 --- a/dbt_semantic_interfaces/implementations/elements/entity.py +++ b/dbt_semantic_interfaces/implementations/elements/entity.py @@ -20,6 +20,7 @@ class PydanticEntity(HashableBaseModel, ModelWithMetadataParsing): role: Optional[str] expr: Optional[str] = None metadata: Optional[PydanticMetadata] = None + label: Optional[str] = None @property def reference(self) -> EntityReference: # noqa: D diff --git a/dbt_semantic_interfaces/implementations/elements/measure.py b/dbt_semantic_interfaces/implementations/elements/measure.py index 1036e1b2..3c7407cc 100644 --- a/dbt_semantic_interfaces/implementations/elements/measure.py +++ b/dbt_semantic_interfaces/implementations/elements/measure.py @@ -44,6 +44,7 @@ class PydanticMeasure(HashableBaseModel, ModelWithMetadataParsing): metadata: Optional[PydanticMetadata] non_additive_dimension: Optional[PydanticNonAdditiveDimensionParameters] = None agg_time_dimension: Optional[str] = None + label: Optional[str] = None @property def reference(self) -> MeasureReference: # noqa: D diff --git a/dbt_semantic_interfaces/implementations/metric.py b/dbt_semantic_interfaces/implementations/metric.py index 014e1e09..111d9bb7 100644 --- a/dbt_semantic_interfaces/implementations/metric.py +++ b/dbt_semantic_interfaces/implementations/metric.py @@ -157,6 +157,7 @@ class PydanticMetric(HashableBaseModel, ModelWithMetadataParsing): type_params: PydanticMetricTypeParams filter: Optional[PydanticWhereFilter] metadata: Optional[PydanticMetadata] + label: Optional[str] = None @property def input_measures(self) -> Sequence[PydanticMetricInputMeasure]: diff --git a/dbt_semantic_interfaces/implementations/saved_query.py b/dbt_semantic_interfaces/implementations/saved_query.py index 937b760a..6ff709b9 100644 --- a/dbt_semantic_interfaces/implementations/saved_query.py +++ b/dbt_semantic_interfaces/implementations/saved_query.py @@ -30,3 +30,4 @@ def _implements_protocol(self) -> SavedQuery: description: Optional[str] = None metadata: Optional[PydanticMetadata] = None + label: Optional[str] = None diff --git a/dbt_semantic_interfaces/implementations/semantic_model.py b/dbt_semantic_interfaces/implementations/semantic_model.py index eb5c098b..45b90ae0 100644 --- a/dbt_semantic_interfaces/implementations/semantic_model.py +++ b/dbt_semantic_interfaces/implementations/semantic_model.py @@ -92,6 +92,7 @@ def _implements_protocol(self) -> SemanticModel: entities: Sequence[PydanticEntity] = [] measures: Sequence[PydanticMeasure] = [] dimensions: Sequence[PydanticDimension] = [] + label: Optional[str] = None metadata: Optional[PydanticMetadata] 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 732ca97d..784ba5b0 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 @@ -45,6 +45,9 @@ "is_partition": { "type": "boolean" }, + "label": { + "type": "string" + }, "name": { "pattern": "(?!.*__).*^[a-z][a-z0-9_]*[a-z0-9]$", "type": "string" @@ -107,6 +110,9 @@ "boolean" ] }, + "label": { + "type": "string" + }, "name": { "pattern": "(?!.*__).*^[a-z][a-z0-9_]*[a-z0-9]$", "type": "string" @@ -195,6 +201,9 @@ "boolean" ] }, + "label": { + "type": "string" + }, "name": { "pattern": "(?!.*__).*^[a-z][a-z0-9_]*[a-z0-9]$", "type": "string" @@ -270,6 +279,9 @@ "filter": { "type": "string" }, + "label": { + "type": "string" + }, "name": { "pattern": "(?!.*__).*^[a-z][a-z0-9_]*[a-z0-9]$", "type": "string" @@ -410,6 +422,9 @@ }, "type": "array" }, + "label": { + "type": "string" + }, "metrics": { "items": { "type": "string" @@ -465,6 +480,9 @@ }, "type": "array" }, + "label": { + "type": "string" + }, "measures": { "items": { "$ref": "#/definitions/measure_schema" diff --git a/dbt_semantic_interfaces/parsing/schemas.py b/dbt_semantic_interfaces/parsing/schemas.py index 69aa64a7..a0256b07 100644 --- a/dbt_semantic_interfaces/parsing/schemas.py +++ b/dbt_semantic_interfaces/parsing/schemas.py @@ -100,6 +100,7 @@ "role": {"type": "string"}, "expr": {"type": ["string", "boolean"]}, "entity": {"type": "string"}, + "label": {"type": "string"}, }, "additionalProperties": False, "required": ["name", "type"], @@ -173,6 +174,7 @@ "$ref": "non_additive_dimension_schema", }, "description": {"type": "string"}, + "label": {"type": "string"}, }, "additionalProperties": False, "required": ["name", "agg"], @@ -191,6 +193,7 @@ "is_partition": {"type": "boolean"}, "expr": {"type": ["string", "boolean"]}, "type_params": {"$ref": "dimension_type_params_schema"}, + "label": {"type": "string"}, }, # dimension must have type_params if its a time dimension "anyOf": [{"not": {"$ref": "#/definitions/is-time-dimension"}}, {"required": ["type_params"]}], @@ -217,6 +220,7 @@ "type_params": {"$ref": "metric_type_params"}, "filter": {"type": "string"}, "description": {"type": "string"}, + "label": {"type": "string"}, }, "additionalProperties": False, "required": ["name", "type", "type_params"], @@ -292,6 +296,7 @@ "type": "array", "items": {"type": "string"}, }, + "label": {"type": "string"}, }, "required": ["name", "metrics"], "additionalProperties": False, @@ -314,6 +319,7 @@ "measures": {"type": "array", "items": {"$ref": "measure_schema"}}, "dimensions": {"type": "array", "items": {"$ref": "dimension_schema"}}, "description": {"type": "string"}, + "label": {"type": "string"}, }, "additionalProperties": False, "required": ["name"], diff --git a/dbt_semantic_interfaces/protocols/dimension.py b/dbt_semantic_interfaces/protocols/dimension.py index d5dc8100..5cf8cbfe 100644 --- a/dbt_semantic_interfaces/protocols/dimension.py +++ b/dbt_semantic_interfaces/protocols/dimension.py @@ -101,3 +101,9 @@ def time_dimension_reference(self) -> Optional[TimeDimensionReference]: def validity_params(self) -> Optional[DimensionValidityParams]: """Returns the DimensionValidityParams if they exist for the dimension implementation.""" ... + + @property + @abstractmethod + def label(self) -> Optional[str]: + """Returns a string representing a human readable label for the dimension.""" + pass diff --git a/dbt_semantic_interfaces/protocols/entity.py b/dbt_semantic_interfaces/protocols/entity.py index 686a713c..356e6803 100644 --- a/dbt_semantic_interfaces/protocols/entity.py +++ b/dbt_semantic_interfaces/protocols/entity.py @@ -54,3 +54,9 @@ def is_linkable_entity_type(self) -> bool: keys reserved for SCD Type II style data sources. """ ... + + @property + @abstractmethod + def label(self) -> Optional[str]: + """Returns a string representing a human readable label for the entity.""" + pass diff --git a/dbt_semantic_interfaces/protocols/measure.py b/dbt_semantic_interfaces/protocols/measure.py index aabdf355..a7588cfb 100644 --- a/dbt_semantic_interfaces/protocols/measure.py +++ b/dbt_semantic_interfaces/protocols/measure.py @@ -92,3 +92,9 @@ def agg_time_dimension(self) -> Optional[str]: # noqa: D def reference(self) -> MeasureReference: """Returns a reference to this measure.""" ... + + @property + @abstractmethod + def label(self) -> Optional[str]: + """Returns a string representing a human readable label for the measure.""" + pass diff --git a/dbt_semantic_interfaces/protocols/metric.py b/dbt_semantic_interfaces/protocols/metric.py index e9eab48a..3f09a29d 100644 --- a/dbt_semantic_interfaces/protocols/metric.py +++ b/dbt_semantic_interfaces/protocols/metric.py @@ -206,3 +206,9 @@ def input_metrics(self) -> Sequence[MetricInput]: @abstractmethod def metadata(self) -> Optional[Metadata]: # noqa: D pass + + @property + @abstractmethod + def label(self) -> Optional[str]: + """Returns a string representing a human readable label for the metric.""" + pass diff --git a/dbt_semantic_interfaces/protocols/saved_query.py b/dbt_semantic_interfaces/protocols/saved_query.py index 0547ba74..2018b164 100644 --- a/dbt_semantic_interfaces/protocols/saved_query.py +++ b/dbt_semantic_interfaces/protocols/saved_query.py @@ -37,3 +37,9 @@ def group_bys(self) -> Sequence[str]: # noqa: D @abstractmethod def where(self) -> Sequence[WhereFilter]: # noqa: D pass + + @property + @abstractmethod + def label(self) -> Optional[str]: + """Returns a string representing a human readable label for the saved query.""" + pass diff --git a/dbt_semantic_interfaces/protocols/semantic_model.py b/dbt_semantic_interfaces/protocols/semantic_model.py index 44e7fac1..6217487e 100644 --- a/dbt_semantic_interfaces/protocols/semantic_model.py +++ b/dbt_semantic_interfaces/protocols/semantic_model.py @@ -173,5 +173,11 @@ def primary_entity_reference(self) -> Optional[EntityReference]: """Reference object form of primary_entity.""" pass + @property + @abstractmethod + def label(self) -> Optional[str]: + """Returns a string representing a human readable label for the semantic model.""" + pass + SemanticModelT = TypeVar("SemanticModelT", bound=SemanticModel) diff --git a/dbt_semantic_interfaces/validations/labels.py b/dbt_semantic_interfaces/validations/labels.py new file mode 100644 index 00000000..f5a19547 --- /dev/null +++ b/dbt_semantic_interfaces/validations/labels.py @@ -0,0 +1,194 @@ +import logging +from collections import defaultdict +from dataclasses import dataclass +from typing import DefaultDict, Dict, Generic, List, Sequence + +from dbt_semantic_interfaces.protocols import Metric, SemanticManifestT, SemanticModel +from dbt_semantic_interfaces.validations.validator_helpers import ( + FileContext, + SemanticManifestValidationRule, + ValidationError, + ValidationIssue, + validate_safely, +) + +logger = logging.getLogger(__name__) + + +class MetricLabelsRule(SemanticManifestValidationRule[SemanticManifestT], Generic[SemanticManifestT]): + """Checks that the labels are unique across metrics.""" + + @staticmethod + @validate_safely("Checking that a metric has a unique label") + def _check_metric(metric: Metric, existing_labels: Dict[str, str]) -> Sequence[ValidationIssue]: # noqa: D + if metric.label in existing_labels: + return ( + ValidationError( + context=FileContext.from_metadata(metric.metadata), + message=f"Can't use label `{metric.label}` for metric `{metric.name}` " + f"as it's already used for metric `{existing_labels[metric.label]}`", + ), + ) + elif metric.label is not None: + existing_labels[metric.label] = metric.name + + return () + + @staticmethod + @validate_safely("Checking labels are unique across metrics") + def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[ValidationIssue]: # noqa: D + issues: List[ValidationIssue] = [] + labels_to_metrics: Dict[str, str] = {} + for metric in semantic_manifest.metrics: + issues += MetricLabelsRule._check_metric(metric=metric, existing_labels=labels_to_metrics) + + return issues + + +class SemanticModelLabelsRule(SemanticManifestValidationRule[SemanticManifestT], Generic[SemanticManifestT]): + """Checks that the labels are unique across semantic models.""" + + @staticmethod + @validate_safely("checking that a semantic model has a unique label") + def _check_semantic_model( + semantic_model: SemanticModel, existing_labels: Dict[str, str] + ) -> Sequence[ValidationIssue]: # noqa: D + if semantic_model.label in existing_labels: + return ( + ValidationError( + context=FileContext.from_metadata(semantic_model.metadata), + message=f"Can't use label `{semantic_model.label}` for semantic model `{semantic_model.name}` " + f"as it's already used for semantic model `{existing_labels[semantic_model.label]}`", + ), + ) + elif semantic_model.label is not None: + existing_labels[semantic_model.label] = semantic_model.name + + return () + + @staticmethod + @validate_safely("checking that a semantic model's dimension labels are unique within itself") + def _check_semantic_model_dimensions(semantic_model: SemanticModel) -> Sequence[ValidationIssue]: + issues: List[ValidationIssue] = [] + label_counts: DefaultDict[str, int] = defaultdict(lambda: 0) + for dimension in semantic_model.dimensions: + if dimension.label is not None: + label_counts[dimension.label] = label_counts[dimension.label] + 1 + + for label, count in label_counts.items(): + if count > 1: + issues.append( + ValidationError( + context=FileContext.from_metadata(semantic_model.metadata), + message=f"Dimension labels must be unique within a semantic model. The label `{label}` was " + f"used for {count} dimensions on semantic model `{semantic_model.name}", + ) + ) + + return issues + + @staticmethod + @validate_safely("checking that a semantic model's entity labels are unique within itself") + def _check_semantic_model_entities(semantic_model: SemanticModel) -> Sequence[ValidationIssue]: + issues: List[ValidationIssue] = [] + label_counts: DefaultDict[str, int] = defaultdict(lambda: 0) + for entity in semantic_model.entities: + if entity.label is not None: + label_counts[entity.label] = label_counts[entity.label] + 1 + + for label, count in label_counts.items(): + if count > 1: + issues.append( + ValidationError( + context=FileContext.from_metadata(semantic_model.metadata), + message=f"Entity labels must be unique within a semantic model. The label `{label}` was used " + f"for {count} entities on semantic model `{semantic_model.name}", + ) + ) + + return issues + + @staticmethod + @validate_safely("checking that a semantic model's measure labels are unique within itself") + def _check_semantic_model_measures(semantic_model: SemanticModel) -> Sequence[ValidationIssue]: + issues: List[ValidationIssue] = [] + label_counts: DefaultDict[str, int] = defaultdict(lambda: 0) + for measure in semantic_model.measures: + if measure.label is not None: + label_counts[measure.label] = label_counts[measure.label] + 1 + + for label, count in label_counts.items(): + if count > 1: + issues.append( + ValidationError( + context=FileContext.from_metadata(semantic_model.metadata), + message=f"Measure labels must be unique within a semantic model. The label `{label}` was used " + f"for {count} measures on semantic model `{semantic_model.name}", + ) + ) + + return issues + + @staticmethod + @validate_safely("checking labels on semantic models and their sub objects") + def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[ValidationIssue]: # noqa: D + issues: List[ValidationIssue] = [] + labels_to_semantic_models: Dict[str, str] = {} + for semantic_model in semantic_manifest.semantic_models: + issues += SemanticModelLabelsRule._check_semantic_model( + semantic_model=semantic_model, existing_labels=labels_to_semantic_models + ) + issues += SemanticModelLabelsRule._check_semantic_model_dimensions(semantic_model=semantic_model) + issues += SemanticModelLabelsRule._check_semantic_model_entities(semantic_model=semantic_model) + issues += SemanticModelLabelsRule._check_semantic_model_measures(semantic_model=semantic_model) + + return issues + + +class EntityLabelsRule(SemanticManifestValidationRule[SemanticManifestT], Generic[SemanticManifestT]): + """Checks that the entity labels are consistent across semantic models.""" + + @dataclass + class EntityInfo: + """Class used in validating of entity labels across semantic models.""" + + semantic_model_name: str + label: str + + @staticmethod + @validate_safely("Checking entities of the same name have the same label (or None for the label)") + def _check_semantic_model_entities( + semantic_model: SemanticModel, existing_labels: Dict[str, EntityInfo] + ) -> Sequence[ValidationIssue]: # noqa: D + issues: List[ValidationIssue] = [] + for entity in semantic_model.entities: + if entity.label is not None: + if entity.name not in existing_labels: + existing_labels[entity.name] = EntityLabelsRule.EntityInfo( + semantic_model_name=semantic_model.name, label=entity.label + ) + elif existing_labels[entity.name].label != entity.label: + issues.append( + ValidationError( + context=FileContext.from_metadata(semantic_model.metadata), + message="Entities with the same name must have the same label or the label must be " + f"`None`. Entity `{entity.name}` on semantic model `{semantic_model.name}` has label " + f"`{entity.label}` but the same entity on semantic model " + f"`{existing_labels[entity.name].semantic_model_name}`", + ) + ) + + return issues + + @staticmethod + @validate_safely("Checking entity labels are consistent across semantic models") + def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[ValidationIssue]: # noqa: D + issues: List[ValidationIssue] = [] + entity_label_map: Dict[str, EntityLabelsRule.EntityInfo] = {} + + for semantic_model in semantic_manifest.semantic_models: + issues += EntityLabelsRule._check_semantic_model_entities( + semantic_model=semantic_model, existing_labels=entity_label_map + ) + + return issues diff --git a/dbt_semantic_interfaces/validations/semantic_manifest_validator.py b/dbt_semantic_interfaces/validations/semantic_manifest_validator.py index 1eef9ffe..2a7de2ea 100644 --- a/dbt_semantic_interfaces/validations/semantic_manifest_validator.py +++ b/dbt_semantic_interfaces/validations/semantic_manifest_validator.py @@ -10,6 +10,11 @@ from dbt_semantic_interfaces.validations.dimension_const import DimensionConsistencyRule from dbt_semantic_interfaces.validations.element_const import ElementConsistencyRule from dbt_semantic_interfaces.validations.entities import NaturalEntityConfigurationRule +from dbt_semantic_interfaces.validations.labels import ( + EntityLabelsRule, + MetricLabelsRule, + SemanticModelLabelsRule, +) from dbt_semantic_interfaces.validations.measures import ( CountAggregationExprRule, MeasureConstraintAliasesRule, @@ -81,6 +86,9 @@ class SemanticManifestValidator(Generic[SemanticManifestT]): PrimaryEntityDimensionPairs[SemanticManifestT](), WhereFiltersAreParseable[SemanticManifestT](), SavedQueryRule[SemanticManifestT](), + MetricLabelsRule[SemanticManifestT](), + SemanticModelLabelsRule[SemanticManifestT](), + EntityLabelsRule[SemanticManifestT](), ) def __init__( diff --git a/pyproject.toml b/pyproject.toml index 992d6506..c2199013 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dependencies = [ "pytest~=7.3", "pytest-xdist~=3.2", "httpx~=0.24", + "hypothesis~=6.87", "pre-commit~=3.2", "isort~=5.12", "black~=23.3", diff --git a/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/metrics.yaml b/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/metrics.yaml index a185117f..33ce26b3 100644 --- a/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/metrics.yaml +++ b/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/metrics.yaml @@ -2,6 +2,7 @@ metric: name: "bookings" description: "bookings metric" + label: "Bookings" type: simple type_params: measure: @@ -10,6 +11,7 @@ metric: metric: name: "average_booking_value" description: "average booking value metric" + label: "Average Bookings Value" type: simple type_params: measure: @@ -18,6 +20,7 @@ metric: metric: name: "instant_bookings" description: "instant bookings" + label: "Instant Bookings" type: simple type_params: measure: diff --git a/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/semantic_models/accounts_source.yaml b/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/semantic_models/accounts_source.yaml index 64167b1a..68032840 100644 --- a/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/semantic_models/accounts_source.yaml +++ b/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/semantic_models/accounts_source.yaml @@ -2,6 +2,7 @@ semantic_model: name: accounts_source description: accounts_source + label: Accounts Source node_relation: schema_name: $source_schema @@ -13,9 +14,11 @@ semantic_model: measures: - name: account_balance agg: sum + label: Account Balance - name: total_account_balance_first_day agg: sum + label: Total Account Balance on First Day expr: account_balance non_additive_dimension: name: ds @@ -23,6 +26,7 @@ semantic_model: - name: current_account_balance_by_user agg: sum + label: Current Account Banance by User expr: account_balance non_additive_dimension: name: ds @@ -33,10 +37,12 @@ semantic_model: dimensions: - name: ds type: time + label: Metric Time type_params: time_granularity: day - name: account_type type: categorical + label: Account Type primary_entity: account @@ -44,3 +50,4 @@ semantic_model: - name: user type: foreign expr: user_id + label: User diff --git a/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/semantic_models/companies.yaml b/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/semantic_models/companies.yaml index 45c94111..fa7cf22c 100644 --- a/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/semantic_models/companies.yaml +++ b/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/semantic_models/companies.yaml @@ -18,3 +18,4 @@ semantic_model: - name: user type: unique expr: user_id + label: User diff --git a/tests/parsing/test_metric_parsing.py b/tests/parsing/test_metric_parsing.py index 51362bfa..d0c07d71 100644 --- a/tests/parsing/test_metric_parsing.py +++ b/tests/parsing/test_metric_parsing.py @@ -72,6 +72,33 @@ def test_legacy_metric_input_measure_object_parsing() -> None: ) +def test_base_metric_parsing() -> None: + """Test parsing base attributes of PydanticMetric object.""" + metric_type = MetricType.SIMPLE + description = "Test metric description" + label = "Base Test" + yaml_contents = textwrap.dedent( + f"""\ + metric: + name: base_test + type: {metric_type.value} + description: {description} + label: {label} + type_params: + measure: + name: metadata_test_measure + """ + ) + file = YamlConfigFile(filepath="test_dir/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.type == metric_type + assert metric.description == description + assert metric.label == label + + def test_metric_metadata_parsing() -> None: """Test for asserting that internal metadata is parsed into the PydanticMetric object.""" yaml_contents = textwrap.dedent( diff --git a/tests/parsing/test_saved_query_parsing.py b/tests/parsing/test_saved_query_parsing.py new file mode 100644 index 00000000..596ee66a --- /dev/null +++ b/tests/parsing/test_saved_query_parsing.py @@ -0,0 +1,135 @@ +import textwrap + +from dbt_semantic_interfaces.parsing.dir_to_model import ( + parse_yaml_files_to_semantic_manifest, +) +from dbt_semantic_interfaces.parsing.objects import YamlConfigFile +from tests.example_project_configuration import ( + EXAMPLE_PROJECT_CONFIGURATION_YAML_CONFIG_FILE, +) + + +def test_saved_query_metadata_parsing() -> None: + """Test for asserting that internal metadata is parsed into the SavedQuery object.""" + yaml_contents = textwrap.dedent( + """\ + saved_query: + name: metadata_test + metrics: + - test_metric + """ + ) + file = YamlConfigFile(filepath="test_dir/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.saved_queries) == 1 + saved_query = build_result.semantic_manifest.saved_queries[0] + assert saved_query.metadata is not None + assert saved_query.metadata.repo_file_path == "test_dir/inline_for_test" + assert saved_query.metadata.file_slice.filename == "inline_for_test" + expected_metadata_content = textwrap.dedent( + """\ + name: metadata_test + metrics: + - test_metric + """ + ) + assert saved_query.metadata.file_slice.content == expected_metadata_content + + +def test_saved_query_base_parsing() -> None: + """Test for base parsing a saved query.""" + name = "base_parsing_test" + description = "the base saved query parsing test" + label = "Base Parsing Test" + yaml_contents = textwrap.dedent( + f"""\ + saved_query: + name: {name} + description: {description} + label: {label} + metrics: + - test_metric + """ + ) + 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.saved_queries) == 1 + saved_query = build_result.semantic_manifest.saved_queries[0] + assert saved_query.name == name + assert saved_query.description == description + assert saved_query.label == label + + +def test_saved_query_metrics_parsing() -> None: + """Test for parsing metrics referenced in a saved query.""" + yaml_contents = textwrap.dedent( + """\ + saved_query: + name: test_saved_query_metrics + metrics: + - test_metric_a + - test_metric_b + - test_metric_c + """ + ) + 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.saved_queries) == 1 + saved_query = build_result.semantic_manifest.saved_queries[0] + assert len(saved_query.metrics) == 3 + assert {"test_metric_a", "test_metric_b", "test_metric_c"} == set(saved_query.metrics) + + +def test_saved_query_group_bys() -> None: + """Test for parsing group_bys in a saved query.""" + yaml_contents = textwrap.dedent( + """\ + saved_query: + name: test_saved_query_group_bys + metrics: + - test_metric_a + group_bys: + - Dimension(test_entity__test_dimension_a) + - Dimension(test_entity__test_dimension_b) + + """ + ) + file = YamlConfigFile(filepath="test_dir/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.saved_queries) == 1 + saved_query = build_result.semantic_manifest.saved_queries[0] + assert len(saved_query.group_bys) == 2 + assert {"Dimension(test_entity__test_dimension_a)", "Dimension(test_entity__test_dimension_b)"} == set( + saved_query.group_bys + ) + + +def test_saved_query_where() -> None: + """Test for parsing where clause in a saved query.""" + where = "Dimension(test_entity__test_dimension) == true" + yaml_contents = textwrap.dedent( + f"""\ + saved_query: + name: test_saved_query_where_clause + metrics: + - test_metric_a + where: + - '{where}' + + """ + ) + file = YamlConfigFile(filepath="test_dir/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.saved_queries) == 1 + saved_query = build_result.semantic_manifest.saved_queries[0] + assert len(saved_query.where) == 1 + assert where == saved_query.where[0].where_sql_template diff --git a/tests/parsing/test_semantic_model_parsing.py b/tests/parsing/test_semantic_model_parsing.py index 442e6444..66ca43cc 100644 --- a/tests/parsing/test_semantic_model_parsing.py +++ b/tests/parsing/test_semantic_model_parsing.py @@ -15,6 +15,31 @@ ) +def test_base_semantic_model_parsing() -> None: + """Test parsing base attributes of PydanticSemanticModel object.""" + description = "Test semantic_model description" + label = "Base Test" + yaml_contents = textwrap.dedent( + f"""\ + semantic_model: + name: base_test + description: {description} + label: {label} + node_relation: + alias: source_table + schema_name: some_schema + """ + ) + file = YamlConfigFile(filepath="test_dir/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.semantic_models) == 1 + semantic_model = build_result.semantic_manifest.semantic_models[0] + assert semantic_model.description == description + assert semantic_model.label == label + + def test_semantic_model_metadata_parsing() -> None: """Test for asserting that internal metadata is parsed into the SemanticModel object.""" yaml_contents = textwrap.dedent( @@ -66,6 +91,35 @@ def test_semantic_model_node_relation_parsing() -> None: assert semantic_model.node_relation.relation_name == "some_schema.source_table" +def test_base_semantic_model_entity_parsing() -> None: + """Test parsing base attributes of PydanticEntity object.""" + label = "Base Test Entity" + yaml_contents = textwrap.dedent( + f"""\ + semantic_model: + name: base_test + node_relation: + alias: source_table + schema_name: some_schema + entities: + - name: test_base_entity + type: primary + role: test_role + expr: example_id + label: {label} + + """ + ) + file = YamlConfigFile(filepath="test_dir/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.semantic_models) == 1 + semantic_model = build_result.semantic_manifest.semantic_models[0] + assert len(semantic_model.entities) == 1 + entity = semantic_model.entities[0] + assert entity.label == label + + def test_semantic_model_entity_parsing() -> None: """Test for parsing a basic entity out of a semantic model specification.""" yaml_contents = textwrap.dedent( @@ -132,6 +186,38 @@ def test_semantic_model_entity_metadata_parsing() -> None: assert entity.metadata.file_slice.content == expected_metadata_content +def test_base_semantic_model_measure_parsing() -> None: + """Test parsing base attributes of PydanticMeasure object.""" + description = "Test semantic_model measure description" + label = "Base Test Measure" + yaml_contents = textwrap.dedent( + f"""\ + semantic_model: + name: base_test + node_relation: + alias: source_table + schema_name: some_schema + measures: + - name: example_measure + agg: count_distinct + expr: example_input + description: {description} + label: {label} + + """ + ) + file = YamlConfigFile(filepath="test_dir/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.semantic_models) == 1 + semantic_model = build_result.semantic_manifest.semantic_models[0] + assert len(semantic_model.measures) == 1 + measure = semantic_model.measures[0] + assert measure.description == description + assert measure.label == label + + def test_semantic_model_measure_parsing() -> None: """Test for parsing a measure out of a semantic model specification.""" yaml_contents = textwrap.dedent( @@ -334,6 +420,37 @@ def test_semantic_model_primary_time_dimension_parsing() -> None: assert dimension.type_params is not None +def test_base_semantic_model_dimension_parsing() -> None: + """Test parsing base attributes of PydanticDimension object.""" + description = "Test semantic_model dimension description" + label = "Base Test Dimension" + yaml_contents = textwrap.dedent( + f"""\ + semantic_model: + name: base_test + node_relation: + alias: source_table + schema_name: some_schema + dimensions: + - name: base_dimension_test + type: categorical + description: {description} + label: {label} + + """ + ) + file = YamlConfigFile(filepath="test_dir/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.semantic_models) == 1 + semantic_model = build_result.semantic_manifest.semantic_models[0] + assert len(semantic_model.dimensions) == 1 + dimension = semantic_model.dimensions[0] + assert dimension.description == description + assert dimension.label == label + + def test_semantic_model_dimension_metadata_parsing() -> None: """Test for parsing metadata for an dimension object defined in a semantic model specification.""" yaml_contents = textwrap.dedent( diff --git a/tests/test_implements_satisfy_protocols.py b/tests/test_implements_satisfy_protocols.py index d618867d..6bc1a6e3 100644 --- a/tests/test_implements_satisfy_protocols.py +++ b/tests/test_implements_satisfy_protocols.py @@ -1,4 +1,7 @@ -from typing import Protocol, runtime_checkable +from typing import List, Protocol, runtime_checkable + +from hypothesis import given +from hypothesis.strategies import booleans, builds, from_type, just, lists, none, text from dbt_semantic_interfaces.implementations.elements.dimension import ( PydanticDimension, @@ -6,40 +9,122 @@ PydanticDimensionValidityParams, ) from dbt_semantic_interfaces.implementations.elements.entity import PydanticEntity -from dbt_semantic_interfaces.implementations.elements.measure import PydanticMeasure -from dbt_semantic_interfaces.implementations.metadata import ( - PydanticFileSlice, - PydanticMetadata, +from dbt_semantic_interfaces.implementations.elements.measure import ( + PydanticMeasure, + PydanticMeasureAggregationParameters, + PydanticNonAdditiveDimensionParameters, +) +from dbt_semantic_interfaces.implementations.filters.where_filter import ( + PydanticWhereFilter, ) +from dbt_semantic_interfaces.implementations.metadata import PydanticMetadata from dbt_semantic_interfaces.implementations.metric import ( PydanticMetric, + PydanticMetricInput, PydanticMetricInputMeasure, PydanticMetricTypeParams, ) +from dbt_semantic_interfaces.implementations.project_configuration import ( + PydanticProjectConfiguration, +) +from dbt_semantic_interfaces.implementations.saved_query import PydanticSavedQuery from dbt_semantic_interfaces.implementations.semantic_manifest import ( PydanticSemanticManifest, ) -from dbt_semantic_interfaces.implementations.semantic_model import ( - NodeRelation, - PydanticSemanticModel, +from dbt_semantic_interfaces.implementations.semantic_model import PydanticSemanticModel +from dbt_semantic_interfaces.implementations.time_spine_table_configuration import ( + PydanticTimeSpineTableConfiguration, ) from dbt_semantic_interfaces.protocols import Dimension as DimensionProtocol from dbt_semantic_interfaces.protocols import Entity as EntityProtocol from dbt_semantic_interfaces.protocols import Measure as MeasureProtocol from dbt_semantic_interfaces.protocols import Metadata as MetadataProtocol from dbt_semantic_interfaces.protocols import Metric as MetricProtocol +from dbt_semantic_interfaces.protocols import SavedQuery as SavedQueryProtocol from dbt_semantic_interfaces.protocols import ( SemanticManifest as SemanticManifestProtocol, ) from dbt_semantic_interfaces.protocols import SemanticModel as SemanticModelProtocol -from dbt_semantic_interfaces.type_enums import ( - AggregationType, - DimensionType, - EntityType, - MetricType, - TimeGranularity, +from dbt_semantic_interfaces.protocols.time_spine_configuration import ( + TimeSpineTableConfiguration as TimeSpineTableConfigurationProtocol, +) +from dbt_semantic_interfaces.type_enums import DimensionType, MetricType + +OPTIONAL_STR_STRATEGY = text() | none() +OPTIONAL_METADATA_STRATEGY = builds(PydanticMetadata) | none() + +CATEGORICAL_DIMENSION_STRATEGY = builds( + PydanticDimension, + description=OPTIONAL_STR_STRATEGY, + type=just(DimensionType.CATEGORICAL), + expr=OPTIONAL_STR_STRATEGY, + metadata=OPTIONAL_METADATA_STRATEGY, + label=OPTIONAL_STR_STRATEGY, +) + +DIMENSION_VALIDITY_PARAMS_STRATEGY = builds( + PydanticDimensionValidityParams, + is_start=just(False), + is_end=just(False), +) + +TIME_DIMENSION_STRATEGY = builds( + PydanticDimension, + description=OPTIONAL_STR_STRATEGY, + type=just(DimensionType.TIME), + type_params=builds(PydanticDimensionTypeParams) | none(), + expr=OPTIONAL_STR_STRATEGY, + metadata=OPTIONAL_METADATA_STRATEGY, + label=OPTIONAL_STR_STRATEGY, +) + +DIMENSION_STRATEGY = TIME_DIMENSION_STRATEGY | CATEGORICAL_DIMENSION_STRATEGY + +ENTITY_STRATEGY = builds( + PydanticEntity, + description=OPTIONAL_STR_STRATEGY, + role=OPTIONAL_STR_STRATEGY, + expr=OPTIONAL_STR_STRATEGY, + metadata=OPTIONAL_METADATA_STRATEGY, + label=OPTIONAL_STR_STRATEGY, +) + +MEASURE_STRATEGY = builds( + PydanticMeasure, + description=OPTIONAL_STR_STRATEGY, + create_metric=booleans() | none(), + expr=OPTIONAL_STR_STRATEGY, + agg_params=builds(PydanticMeasureAggregationParameters) | none(), + non_additive_dimesnion=builds(PydanticNonAdditiveDimensionParameters) | none(), + agg_time_dimension=OPTIONAL_STR_STRATEGY, + label=OPTIONAL_STR_STRATEGY, +) + +SEMANTIC_MODEL_STRATEGY = builds( + PydanticSemanticModel, + dimensions=lists(DIMENSION_STRATEGY), + entities=lists(ENTITY_STRATEGY), + measures=lists(MEASURE_STRATEGY), +) + +SIMPLE_METRIC_STRATEGY = builds( + PydanticMetric, + description=OPTIONAL_STR_STRATEGY, + type=just(MetricType.SIMPLE), + type_params=builds(PydanticMetricTypeParams, measure=builds(PydanticMetricInputMeasure)), + filter=builds(PydanticWhereFilter) | none(), + metadata=OPTIONAL_METADATA_STRATEGY, + label=OPTIONAL_STR_STRATEGY, +) + +SAVED_QUERY_STRATEGY = builds( + PydanticSavedQuery, + group_bys=from_type(List[str]), + where=from_type(List[PydanticWhereFilter]), + description=OPTIONAL_STR_STRATEGY, + metadata=OPTIONAL_METADATA_STRATEGY, + label=OPTIONAL_STR_STRATEGY, ) -from tests.example_project_configuration import EXAMPLE_PROJECT_CONFIGURATION @runtime_checkable @@ -49,27 +134,16 @@ class RuntimeCheckableSemanticManifest(SemanticManifestProtocol, Protocol): pass -def test_semantic_manifest_protocol() -> None: # noqa: D - semantic_model = PydanticSemanticModel( - name="test_semantic_model", - node_relation=NodeRelation( - alias="test_alias", - schema_name="test_schema_name", - ), - entities=[], - measures=[], - dimensions=[], - ) - metric = PydanticMetric( - name="test_metric", - type=MetricType.SIMPLE, - type_params=PydanticMetricTypeParams(measure=PydanticMetricInputMeasure(name="test_measure")), - ) - semantic_manifest = PydanticSemanticManifest( - semantic_models=[semantic_model], - metrics=[metric], - project_configuration=EXAMPLE_PROJECT_CONFIGURATION, +@given( + builds( + PydanticSemanticManifest, + semantic_models=lists(SEMANTIC_MODEL_STRATEGY), + metrics=lists(SIMPLE_METRIC_STRATEGY), + saved_queries=lists(SAVED_QUERY_STRATEGY), + project_configuration=builds(PydanticProjectConfiguration), ) +) +def test_semantic_manifest_protocol(semantic_manifest: PydanticSemanticManifest) -> None: # noqa: D assert isinstance(semantic_manifest, RuntimeCheckableSemanticManifest) @@ -80,18 +154,9 @@ class RuntimeCheckableSemanticModel(SemanticModelProtocol, Protocol): pass -def test_semantic_model_protocol() -> None: # noqa: D - test_semantic_model = PydanticSemanticModel( - name="test_semantic_model", - node_relation=NodeRelation( - alias="test_alias", - schema_name="test_schema_name", - ), - entities=[], - measures=[], - dimensions=[], - ) - assert isinstance(test_semantic_model, RuntimeCheckableSemanticModel) +@given(SEMANTIC_MODEL_STRATEGY) +def test_semantic_model_protocol(semantic_model: PydanticSemanticModel) -> None: # noqa: D + assert isinstance(semantic_model, RuntimeCheckableSemanticModel) @runtime_checkable @@ -101,13 +166,36 @@ class RuntimeCheckableMetric(MetricProtocol, Protocol): pass -def test_metric_protocol() -> None: # noqa: D - test_metric = PydanticMetric( - name="test_metric", - type=MetricType.SIMPLE, - type_params=PydanticMetricTypeParams(measure=PydanticMetricInputMeasure(name="test_measure")), +@given(SIMPLE_METRIC_STRATEGY) +def test_metric_protocol_simple(metric: PydanticMetric) -> None: # noqa: D + assert isinstance(metric, RuntimeCheckableMetric) + + +@given( + builds( + PydanticMetric, + type=just(MetricType.RATIO), + type_params=builds( + PydanticMetricTypeParams, + numerator=builds(PydanticMetricInput), + denominator=builds(PydanticMetricInput), + ), + ) +) +def test_metric_protocol_ratio(metric: PydanticMetric) -> None: # noqa: D + assert isinstance(metric, RuntimeCheckableMetric) + + +@given( + builds( + PydanticMetric, + type=just(MetricType.DERIVED), + type_params=builds(PydanticMetricTypeParams, metrics=lists(builds(PydanticMetricInput))), + expr=builds(str), ) - assert isinstance(test_metric, RuntimeCheckableMetric) +) +def test_metric_protocol_derived(metric: PydanticMetric) -> None: # noqa: D + assert isinstance(metric, RuntimeCheckableMetric) @runtime_checkable @@ -117,12 +205,9 @@ class RuntimeCheckableEntity(EntityProtocol, Protocol): pass -def test_entity_protocol() -> None: # noqa: D - test_entity = PydanticEntity( - name="test_name", - type=EntityType.PRIMARY, - ) - assert isinstance(test_entity, RuntimeCheckableEntity) +@given(ENTITY_STRATEGY) +def test_entity_protocol(entity: PydanticEntity) -> None: # noqa: D + assert isinstance(entity, RuntimeCheckableEntity) @runtime_checkable @@ -132,13 +217,9 @@ class RuntimeCheckableMeasure(MeasureProtocol, Protocol): pass -def test_measure_protocol() -> None: # noqa: D - test_measure = PydanticMeasure( - name="test_measure", - agg=AggregationType.SUM, - agg_time_dimension="some_time_dimension", - ) - assert isinstance(test_measure, RuntimeCheckableMeasure) +@given(MEASURE_STRATEGY) +def test_measure_protocol(measure: PydanticMeasure) -> None: # noqa: D + assert isinstance(measure, RuntimeCheckableMeasure) @runtime_checkable @@ -148,26 +229,9 @@ class RuntimeCheckableDimension(DimensionProtocol, Protocol): pass -def test_dimension_protocol() -> None: # noqa: D - time_dim = PydanticDimension( - name="test_time_dim", - type=DimensionType.TIME, - type_params=PydanticDimensionTypeParams( - time_granularity=TimeGranularity.DAY, - validity_params=PydanticDimensionValidityParams(), - ), - ) - assert isinstance(time_dim, RuntimeCheckableDimension) - - # Skipping this assertion because are implementation of the function `time_dimension_reference` raises an - # exception if DimensionType != TIME. The isinstance check seems to actually run the function thus - # raising an exception during the assertion. - # of - # categorical_dim = PydanticDimension( - # name="test_categorical_dim", - # type=DimensionType.CATEGORICAL, - # ) - # assert isinstance(categorical_dim, RuntimeCheckableDimension) +@given(DIMENSION_STRATEGY) +def test_dimension_protocol(dimension: PydanticDimension) -> None: # noqa: D + assert isinstance(dimension, RuntimeCheckableDimension) @runtime_checkable @@ -177,14 +241,30 @@ class RuntimeCheckableMetadata(MetadataProtocol, Protocol): pass -def test_metadata_protocol() -> None: # noqa: D - metadata = PydanticMetadata( - repo_file_path="/path/to/cats.txt", - file_slice=PydanticFileSlice( - filename="cats.txt", - content="I like cats", - start_line_number=0, - end_line_number=1, - ), - ) +@given(builds(PydanticMetadata)) +def test_metadata_protocol(metadata: PydanticMetadata) -> None: # noqa: D assert isinstance(metadata, RuntimeCheckableMetadata) + + +@runtime_checkable +class RuntimeCheckableSavedQuery(SavedQueryProtocol, Protocol): + """We don't want runtime_checkable versions of protocols in the package, but we want them for tests.""" + + pass + + +@given(SAVED_QUERY_STRATEGY) +def test_saved_query_protocol(saved_query: PydanticSavedQuery) -> None: # noqa: D + assert isinstance(saved_query, RuntimeCheckableSavedQuery) + + +@runtime_checkable +class RuntimeCheckableTimeSpineConfiguration(TimeSpineTableConfigurationProtocol, Protocol): + """We don't want runtime_checkable versions of protocols in the package, but we want them for tests.""" + + pass + + +@given(builds(PydanticTimeSpineTableConfiguration)) +def test_time_spine_table_configuration_protocol(time_spine: PydanticTimeSpineTableConfiguration) -> None: # noqa: D + assert isinstance(time_spine, RuntimeCheckableTimeSpineConfiguration) diff --git a/tests/validations/test_labels.py b/tests/validations/test_labels.py new file mode 100644 index 00000000..ee65e45b --- /dev/null +++ b/tests/validations/test_labels.py @@ -0,0 +1,155 @@ +from copy import deepcopy + +import pytest + +from dbt_semantic_interfaces.implementations.elements.entity import PydanticEntity +from dbt_semantic_interfaces.implementations.semantic_manifest import ( + PydanticSemanticManifest, +) +from dbt_semantic_interfaces.test_utils import ( + find_metric_with, + find_semantic_model_with, +) +from dbt_semantic_interfaces.type_enums import EntityType +from dbt_semantic_interfaces.validations.labels import ( + EntityLabelsRule, + MetricLabelsRule, + SemanticModelLabelsRule, +) +from dbt_semantic_interfaces.validations.semantic_manifest_validator import ( + SemanticManifestValidator, +) +from dbt_semantic_interfaces.validations.validator_helpers import ( + SemanticManifestValidationException, +) + + +def test_metric_label_happy_path( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = deepcopy(simple_semantic_manifest__with_primary_transforms) + SemanticManifestValidator[PydanticSemanticManifest]( + [MetricLabelsRule[PydanticSemanticManifest]()] + ).checked_validations(manifest) + + +def test_duplicate_metric_label( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = deepcopy(simple_semantic_manifest__with_primary_transforms) + metric = find_metric_with(manifest, lambda metric: metric.label is not None) + duplicated_metric, _ = deepcopy(metric) + duplicated_metric.name = duplicated_metric.name + "_copy" + manifest.metrics.append(duplicated_metric) + with pytest.raises( + SemanticManifestValidationException, + match=rf"Can't use label `{duplicated_metric.label}` for metric", + ): + SemanticManifestValidator[PydanticSemanticManifest]( + [MetricLabelsRule[PydanticSemanticManifest]()] + ).checked_validations(manifest) + + +def test_semantic_model_label_happy_path( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = deepcopy(simple_semantic_manifest__with_primary_transforms) + SemanticManifestValidator[PydanticSemanticManifest]( + [SemanticModelLabelsRule[PydanticSemanticManifest]()] + ).checked_validations(manifest) + + +def test_semantic_model_with_duplicate_labels( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = deepcopy(simple_semantic_manifest__with_primary_transforms) + semantic_model, _ = find_semantic_model_with(manifest, lambda semantic_model: semantic_model.label is not None) + duplicate = deepcopy(semantic_model) + duplicate.name = duplicate.name + "_duplicate" + manifest.semantic_models.append(duplicate) + with pytest.raises( + SemanticManifestValidationException, + match=rf"Can't use label `{semantic_model.label}` for semantic model", + ): + SemanticManifestValidator[PydanticSemanticManifest]( + [SemanticModelLabelsRule[PydanticSemanticManifest]()] + ).checked_validations(manifest) + + +def test_semantic_model_with_duplicate_dimension_labels( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = deepcopy(simple_semantic_manifest__with_primary_transforms) + semantic_model, _ = find_semantic_model_with(manifest, lambda semantic_model: len(semantic_model.dimensions) >= 2) + label = "Duplicate Label Name" + semantic_model.dimensions[0].label = label + semantic_model.dimensions[1].label = label + with pytest.raises( + SemanticManifestValidationException, + match=rf"Dimension labels must be unique within a semantic model. The label `{label}`", + ): + SemanticManifestValidator[PydanticSemanticManifest]( + [SemanticModelLabelsRule[PydanticSemanticManifest]()] + ).checked_validations(manifest) + + +def test_semantic_model_with_duplicate_entity_labels( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = deepcopy(simple_semantic_manifest__with_primary_transforms) + semantic_model, _ = find_semantic_model_with(manifest, lambda semantic_model: len(semantic_model.entities) >= 2) + label = "Duplicate Label Name" + semantic_model.entities[0].label = label + semantic_model.entities[1].label = label + with pytest.raises( + SemanticManifestValidationException, + match=rf"Entity labels must be unique within a semantic model. The label `{label}`", + ): + SemanticManifestValidator[PydanticSemanticManifest]( + [SemanticModelLabelsRule[PydanticSemanticManifest]()] + ).checked_validations(manifest) + + +def test_semantic_model_with_duplicate_measure_labels( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = deepcopy(simple_semantic_manifest__with_primary_transforms) + semantic_model, _ = find_semantic_model_with(manifest, lambda semantic_model: len(semantic_model.measures) >= 2) + label = "Duplicate Label Name" + semantic_model.measures[0].label = label + semantic_model.measures[1].label = label + with pytest.raises( + SemanticManifestValidationException, + match=rf"Measure labels must be unique within a semantic model. The label `{label}`", + ): + SemanticManifestValidator[PydanticSemanticManifest]( + [SemanticModelLabelsRule[PydanticSemanticManifest]()] + ).checked_validations(manifest) + + +def test_entity_labels_happy_path( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = deepcopy(simple_semantic_manifest__with_primary_transforms) + SemanticManifestValidator[PydanticSemanticManifest]( + [EntityLabelsRule[PydanticSemanticManifest]()] + ).checked_validations(manifest) + + +def test_entities_with_same_name_but_different_labels( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = deepcopy(simple_semantic_manifest__with_primary_transforms) + entity = PydanticEntity(name="random_entity", type=EntityType.FOREIGN, label="Random Entity") + entity_conflict = PydanticEntity(name="random_entity", type=EntityType.FOREIGN, label="Random Entity Scoped") + manifest.semantic_models[0].entities = list(manifest.semantic_models[0].entities) + [entity] + manifest.semantic_models[1].entities = list(manifest.semantic_models[1].entities) + [entity_conflict] + + with pytest.raises( + SemanticManifestValidationException, + match=rf"Entities with the same name must have the same label or the label must be `None`. Entity " + f"`{entity.name}`", + ): + SemanticManifestValidator[PydanticSemanticManifest]( + [EntityLabelsRule[PydanticSemanticManifest]()] + ).checked_validations(manifest)