Skip to content

Commit

Permalink
Add validations for time spines (#344)
Browse files Browse the repository at this point in the history
### Description
Validations for time spines. 
- Enforce basic naming restrictions
- Ensure uniqueness across dimensions, entities, measures, metrics,
semantic models, other custom granularities, and standard granularities
- Ensure time granularity options in where filters are valid

Also bumps to a new production version. This new version of DSI will
need to be released to mantle before custom calendar is released as a
feature.

### Checklist

- [x] I have read [the contributing
guide](https://github.com/dbt-labs/dbt-semantic-interfaces/blob/main/CONTRIBUTING.md)
and understand what's expected of me
- [x] I have signed the
[CLA](https://docs.getdbt.com/docs/contributor-license-agreements)
- [x] This PR includes tests, or tests are not required/relevant for
this PR
- [ ] I have run `changie new` to [create a changelog
entry](https://github.com/dbt-labs/dbt-semantic-interfaces/blob/main/CONTRIBUTING.md#adding-a-changelog-entry)
  • Loading branch information
courtneyholcomb authored Sep 26, 2024
1 parent adcce4a commit b81abec
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ class SemanticManifestNodeType(ExtendedEnum):
METRIC = "metric"
SAVED_QUERY = "saved_query"
SEMANTIC_MODEL = "semantic_model"
TIME_SPINE = "time_spine"
73 changes: 69 additions & 4 deletions dbt_semantic_interfaces/validations/metrics.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import traceback
from typing import Dict, Generic, List, Optional, Sequence, Set
from typing import Dict, Generic, List, Optional, Sequence, Set, Tuple

from dbt_semantic_interfaces.call_parameter_sets import FilterCallParameterSets
from dbt_semantic_interfaces.errors import ParsingException
from dbt_semantic_interfaces.implementations.metric import (
PydanticMetric,
Expand Down Expand Up @@ -262,11 +263,37 @@ def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[Validati
class WhereFiltersAreParseable(SemanticManifestValidationRule[SemanticManifestT], Generic[SemanticManifestT]):
"""Validates that all Metric WhereFilters are parseable."""

@staticmethod
def _validate_time_granularity_names(
context: MetricContext,
filter_expression_parameter_sets: Sequence[Tuple[str, FilterCallParameterSets]],
custom_granularity_names: List[str],
) -> Sequence[ValidationIssue]:
issues: List[ValidationIssue] = []

valid_granularity_names = [
standard_granularity.name for standard_granularity in TimeGranularity
] + custom_granularity_names
for _, parameter_set in filter_expression_parameter_sets:
for time_dim_call_parameter_set in parameter_set.time_dimension_call_parameter_sets:
if not time_dim_call_parameter_set.time_granularity_name:
continue
if time_dim_call_parameter_set.time_granularity_name not in valid_granularity_names:
issues.append(
ValidationWarning(
context=context,
message=f"Filter for metric `{context.metric.metric_name}` is not valid. "
f"`{time_dim_call_parameter_set.time_granularity_name}` is not a valid granularity name. "
f"Valid granularity options: {valid_granularity_names}",
)
)
return issues

@staticmethod
@validate_safely(
whats_being_done="running model validation ensuring a metric's filter properties are configured properly"
)
def _validate_metric(metric: Metric) -> Sequence[ValidationIssue]: # noqa: D
def _validate_metric(metric: Metric, custom_granularity_names: List[str]) -> Sequence[ValidationIssue]: # noqa: D
issues: List[ValidationIssue] = []
context = MetricContext(
file_context=FileContext.from_metadata(metadata=metric.metadata),
Expand All @@ -287,6 +314,12 @@ def _validate_metric(metric: Metric) -> Sequence[ValidationIssue]: # noqa: D
},
)
)
else:
issues += WhereFiltersAreParseable._validate_time_granularity_names(
context=context,
filter_expression_parameter_sets=metric.filter.filter_expression_parameter_sets,
custom_granularity_names=custom_granularity_names,
)

if metric.type_params:
measure = metric.type_params.measure
Expand All @@ -305,6 +338,12 @@ def _validate_metric(metric: Metric) -> Sequence[ValidationIssue]: # noqa: D
},
)
)
else:
issues += WhereFiltersAreParseable._validate_time_granularity_names(
context=context,
filter_expression_parameter_sets=measure.filter.filter_expression_parameter_sets,
custom_granularity_names=custom_granularity_names,
)

numerator = metric.type_params.numerator
if numerator is not None and numerator.filter is not None:
Expand All @@ -321,6 +360,12 @@ def _validate_metric(metric: Metric) -> Sequence[ValidationIssue]: # noqa: D
},
)
)
else:
issues += WhereFiltersAreParseable._validate_time_granularity_names(
context=context,
filter_expression_parameter_sets=numerator.filter.filter_expression_parameter_sets,
custom_granularity_names=custom_granularity_names,
)

denominator = metric.type_params.denominator
if denominator is not None and denominator.filter is not None:
Expand All @@ -337,6 +382,12 @@ def _validate_metric(metric: Metric) -> Sequence[ValidationIssue]: # noqa: D
},
)
)
else:
issues += WhereFiltersAreParseable._validate_time_granularity_names(
context=context,
filter_expression_parameter_sets=denominator.filter.filter_expression_parameter_sets,
custom_granularity_names=custom_granularity_names,
)

for input_metric in metric.type_params.metrics or []:
if input_metric.filter is not None:
Expand All @@ -354,15 +405,29 @@ def _validate_metric(metric: Metric) -> Sequence[ValidationIssue]: # noqa: D
},
)
)
else:
issues += WhereFiltersAreParseable._validate_time_granularity_names(
context=context,
filter_expression_parameter_sets=input_metric.filter.filter_expression_parameter_sets,
custom_granularity_names=custom_granularity_names,
)

# TODO: Are saved query filters being validated? Task: SL-2932
return issues

@staticmethod
@validate_safely(whats_being_done="running manifest validation ensuring all metric where filters are parseable")
def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[ValidationIssue]: # noqa: D
issues: List[ValidationIssue] = []

custom_granularity_names = [
granularity.name
for time_spine in semantic_manifest.project_configuration.time_spines
for granularity in time_spine.custom_granularities
]
for metric in semantic_manifest.metrics or []:
issues += WhereFiltersAreParseable._validate_metric(metric)
issues += WhereFiltersAreParseable._validate_metric(
metric=metric, custom_granularity_names=custom_granularity_names
)
return issues


Expand Down
86 changes: 65 additions & 21 deletions dbt_semantic_interfaces/validations/unique_valid_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import enum
import re
from typing import Dict, Generic, List, Optional, Sequence, Tuple, Union
from typing import Dict, Generic, List, Optional, Sequence, Set, Tuple, Union

from dbt_semantic_interfaces.enum_extension import assert_values_exhausted
from dbt_semantic_interfaces.protocols import (
Expand Down Expand Up @@ -102,12 +102,14 @@ def check_valid_name(name: str, context: Optional[ValidationContext] = None) ->

@staticmethod
@validate_safely(whats_being_done="checking semantic model sub element names are unique")
def _validate_semantic_model_elements(semantic_model: SemanticModel) -> List[ValidationIssue]:
def _validate_semantic_model_elements_and_time_spines(semantic_manifest: SemanticManifest) -> List[ValidationIssue]:
issues: List[ValidationIssue] = []
element_info_tuples: List[Tuple[ElementReference, str, ValidationContext]] = []
custom_granularity_restricted_names_and_types: Dict[str, str] = {}

if semantic_model.measures:
for semantic_model in semantic_manifest.semantic_models:
element_info_tuples: List[Tuple[ElementReference, str, ValidationContext]] = []
for measure in semantic_model.measures:
custom_granularity_restricted_names_and_types[measure.name] = SemanticModelElementType.MEASURE.value
element_info_tuples.append(
(
measure.reference,
Expand All @@ -121,8 +123,8 @@ def _validate_semantic_model_elements(semantic_model: SemanticModel) -> List[Val
),
)
)
if semantic_model.entities:
for entity in semantic_model.entities:
custom_granularity_restricted_names_and_types[entity.name] = SemanticModelElementType.ENTITY.value
element_info_tuples.append(
(
entity.reference,
Expand All @@ -136,8 +138,8 @@ def _validate_semantic_model_elements(semantic_model: SemanticModel) -> List[Val
),
)
)
if semantic_model.dimensions:
for dimension in semantic_model.dimensions:
custom_granularity_restricted_names_and_types[dimension.name] = SemanticModelElementType.DIMENSION.value
element_info_tuples.append(
(
dimension.reference,
Expand All @@ -151,22 +153,66 @@ def _validate_semantic_model_elements(semantic_model: SemanticModel) -> List[Val
),
)
)
name_to_type: Dict[ElementReference, str] = {}

for name, _type, context in element_info_tuples:
if name in name_to_type:
issues.append(
ValidationError(
context=context,
message=f"In semantic model `{semantic_model.name}`, can't use name `{name.element_name}` for "
f"a {_type} when it was already used for a {name_to_type[name]}",
# Verify uniqueness for this type within each semantic model
semantic_model_element_reference_to_type: Dict[ElementReference, str] = {}
for reference, _type, context in element_info_tuples:
if reference in semantic_model_element_reference_to_type:
issues.append(
ValidationError(
context=context,
message=f"In semantic model `{semantic_model.name}`, can't use name "
f"`{reference.element_name}` for a {_type} when it was already used for a "
f"{semantic_model_element_reference_to_type[reference]}",
)
)
else:
semantic_model_element_reference_to_type[reference] = _type

for name, _, context in element_info_tuples:
issues += UniqueAndValidNameRule.check_valid_name(name=name.element_name, context=context)

for metric in semantic_manifest.metrics:
custom_granularity_restricted_names_and_types[metric.name] = SemanticManifestNodeType.METRIC.value
for semantic_model in semantic_manifest.semantic_models:
custom_granularity_restricted_names_and_types[
semantic_model.name
] = SemanticManifestNodeType.SEMANTIC_MODEL.value

# Verify custom granularity names are unique across relevant elements
seen_custom_granularity_names: Set[str] = set()
duplicate_custom_granularity_names: Set[str] = set()
for time_spine in semantic_manifest.project_configuration.time_spines:
time_spine_context = ValidationIssueContext(
file_context=FileContext(),
object_name=time_spine.node_relation.alias,
object_type=SemanticManifestNodeType.TIME_SPINE.value,
)
for custom_granularity in time_spine.custom_granularities:
issues += UniqueAndValidNameRule.check_valid_name(
name=custom_granularity.name, context=time_spine_context
)
else:
name_to_type[name] = _type
if custom_granularity.name in custom_granularity_restricted_names_and_types:
issues.append(
ValidationError(
context=time_spine_context,
message=f"Can't use name `{custom_granularity.name}` for a custom granularity when it was "
"already used for a "
f"{custom_granularity_restricted_names_and_types[custom_granularity.name]}.",
)
)
if custom_granularity.name in seen_custom_granularity_names:
duplicate_custom_granularity_names.add(custom_granularity.name)
seen_custom_granularity_names.add(custom_granularity.name)

for name, _, context in element_info_tuples:
issues += UniqueAndValidNameRule.check_valid_name(name=name.element_name, context=context)
if duplicate_custom_granularity_names:
issues.append(
ValidationError(
context=time_spine_context,
message=f"Custom granularity names must be unique, but found duplicate custom granularities with "
f"the names {duplicate_custom_granularity_names}.",
)
)

return issues

Expand Down Expand Up @@ -230,9 +276,7 @@ def _validate_top_level_objects(semantic_manifest: SemanticManifest) -> List[Val
def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[ValidationIssue]: # noqa: D
issues = []
issues += UniqueAndValidNameRule._validate_top_level_objects(semantic_manifest=semantic_manifest)

for semantic_model in semantic_manifest.semantic_models:
issues += UniqueAndValidNameRule._validate_semantic_model_elements(semantic_model=semantic_model)
issues += UniqueAndValidNameRule._validate_semantic_model_elements_and_time_spines(semantic_manifest)

return issues

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "dbt-semantic-interfaces"
version = "0.7.2.dev0"
version = "0.7.2"
description = 'The shared semantic layer definitions that dbt-core and MetricFlow use'
readme = "README.md"
requires-python = ">=3.8"
Expand Down
23 changes: 23 additions & 0 deletions tests/validations/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,29 @@ def test_where_filter_validations_bad_input_metric_filter( # noqa: D
validator.checked_validations(manifest)


def test_where_filter_validations_invalid_granularity( # 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.type_params is not None
and metric.type_params.metrics is not None
and len(metric.type_params.metrics) > 0,
)
assert metric.type_params.metrics is not None
input_metric = metric.type_params.metrics[0]
input_metric.filter = PydanticWhereFilterIntersection(
where_filters=[PydanticWhereFilter(where_sql_template="{{ TimeDimension('metric_time', 'cool') }}")]
)
validator = SemanticManifestValidator[PydanticSemanticManifest]([WhereFiltersAreParseable()])
issues = validator.validate_semantic_manifest(manifest)
assert not issues.has_blocking_issues
assert len(issues.warnings) == 1
assert "`cool` is not a valid granularity name" in issues.warnings[0].message


def test_conversion_metrics() -> None: # noqa: D
base_measure_name = "base_measure"
conversion_measure_name = "conversion_measure"
Expand Down
Loading

0 comments on commit b81abec

Please sign in to comment.