diff --git a/.changes/unreleased/Features-20230913-172754.yaml b/.changes/unreleased/Features-20230913-172754.yaml new file mode 100644 index 00000000..85ce7f2f --- /dev/null +++ b/.changes/unreleased/Features-20230913-172754.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Add New SavedQuery Protocol +time: 2023-09-13T17:27:54.018355-07:00 +custom: + Author: plypaul + Issue: "144" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fccc21e9..34ea7a5f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,7 @@ repos: - id: ruff verbose: true language: system + args: [--fix] - repo: https://github.com/pre-commit/mirrors-mypy # configured via mypy.ini rev: v1.3.0 diff --git a/dbt_semantic_interfaces/implementations/saved_query.py b/dbt_semantic_interfaces/implementations/saved_query.py new file mode 100644 index 00000000..937b760a --- /dev/null +++ b/dbt_semantic_interfaces/implementations/saved_query.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import List, Optional + +from typing_extensions import override + +from dbt_semantic_interfaces.implementations.base import ( + HashableBaseModel, + ModelWithMetadataParsing, +) +from dbt_semantic_interfaces.implementations.filters.where_filter import ( + PydanticWhereFilter, +) +from dbt_semantic_interfaces.implementations.metadata import PydanticMetadata +from dbt_semantic_interfaces.protocols import ProtocolHint +from dbt_semantic_interfaces.protocols.saved_query import SavedQuery + + +class PydanticSavedQuery(HashableBaseModel, ModelWithMetadataParsing, ProtocolHint[SavedQuery]): + """Pydantic implementation of SavedQuery.""" + + @override + def _implements_protocol(self) -> SavedQuery: + return self + + name: str + metrics: List[str] + group_bys: List[str] = [] + where: List[PydanticWhereFilter] = [] + + description: Optional[str] = None + metadata: Optional[PydanticMetadata] = None diff --git a/dbt_semantic_interfaces/implementations/semantic_manifest.py b/dbt_semantic_interfaces/implementations/semantic_manifest.py index 3556df62..c8ce48ae 100644 --- a/dbt_semantic_interfaces/implementations/semantic_manifest.py +++ b/dbt_semantic_interfaces/implementations/semantic_manifest.py @@ -7,6 +7,7 @@ from dbt_semantic_interfaces.implementations.project_configuration import ( PydanticProjectConfiguration, ) +from dbt_semantic_interfaces.implementations.saved_query import PydanticSavedQuery from dbt_semantic_interfaces.implementations.semantic_model import PydanticSemanticModel from dbt_semantic_interfaces.protocols import ProtocolHint, SemanticManifest @@ -21,3 +22,4 @@ def _implements_protocol(self) -> SemanticManifest: semantic_models: List[PydanticSemanticModel] metrics: List[PydanticMetric] project_configuration: PydanticProjectConfiguration + saved_queries: List[PydanticSavedQuery] = [] diff --git a/dbt_semantic_interfaces/parsing/dir_to_model.py b/dbt_semantic_interfaces/parsing/dir_to_model.py index ad9a8f6d..418ca254 100644 --- a/dbt_semantic_interfaces/parsing/dir_to_model.py +++ b/dbt_semantic_interfaces/parsing/dir_to_model.py @@ -12,6 +12,7 @@ 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, ) @@ -20,6 +21,7 @@ from dbt_semantic_interfaces.parsing.schemas import ( metric_validator, project_configuration_validator, + saved_query_validator, semantic_model_validator, ) from dbt_semantic_interfaces.parsing.yaml_loader import ( @@ -45,8 +47,9 @@ METRIC_TYPE = "metric" SEMANTIC_MODEL_TYPE = "semantic_model" PROJECT_CONFIGURATION_TYPE = "project_configuration" +SAVED_QUERY_TYPE = "saved_query" -DOCUMENT_TYPES = [METRIC_TYPE, SEMANTIC_MODEL_TYPE, PROJECT_CONFIGURATION_TYPE] +DOCUMENT_TYPES = [METRIC_TYPE, SEMANTIC_MODEL_TYPE, PROJECT_CONFIGURATION_TYPE, SAVED_QUERY_TYPE] @dataclass(frozen=True) @@ -65,7 +68,7 @@ class FileParsingResult: issues: Issues found when trying to parse the file """ - elements: List[Union[PydanticSemanticModel, PydanticMetric, PydanticProjectConfiguration]] + elements: List[Union[PydanticSemanticModel, PydanticMetric, PydanticProjectConfiguration, PydanticSavedQuery]] issues: List[ValidationIssue] @@ -191,6 +194,7 @@ def parse_yaml_files_to_semantic_manifest( semantic_model_class: Type[PydanticSemanticModel] = PydanticSemanticModel, metric_class: Type[PydanticMetric] = PydanticMetric, project_configuration_class: Type[PydanticProjectConfiguration] = PydanticProjectConfiguration, + saved_query_class: Type[PydanticSavedQuery] = PydanticSavedQuery, ) -> SemanticManifestBuildResult: """Builds SemanticManifest from list of config files (as strings). @@ -202,7 +206,14 @@ def parse_yaml_files_to_semantic_manifest( semantic_models = [] metrics = [] project_configurations = [] - valid_object_classes = [semantic_model_class.__name__, metric_class.__name__, project_configuration_class.__name__] + saved_queries = [] + + valid_object_classes = [ + semantic_model_class.__name__, + metric_class.__name__, + project_configuration_class.__name__, + saved_query_class.__name__, + ] issues: List[ValidationIssue] = [] for config_file in files: @@ -211,6 +222,7 @@ def parse_yaml_files_to_semantic_manifest( semantic_model_class=semantic_model_class, metric_class=metric_class, project_configuration_class=project_configuration_class, + saved_query_class=saved_query_class, ) file_issues = parsing_result.issues for obj in parsing_result.elements: @@ -220,6 +232,8 @@ def parse_yaml_files_to_semantic_manifest( metrics.append(obj) elif isinstance(obj, project_configuration_class): project_configurations.append(obj) + elif isinstance(obj, saved_query_class): + saved_queries.append(obj) else: file_issues.append( ValidationError( @@ -241,6 +255,7 @@ def parse_yaml_files_to_semantic_manifest( semantic_models=semantic_models, metrics=metrics, project_configuration=project_configurations[0], + saved_queries=saved_queries, ), issues=SemanticManifestValidationResults.from_issues_sequence(issues), ) @@ -251,9 +266,10 @@ def parse_config_yaml( semantic_model_class: Type[PydanticSemanticModel] = PydanticSemanticModel, metric_class: Type[PydanticMetric] = PydanticMetric, project_configuration_class: Type[PydanticProjectConfiguration] = PydanticProjectConfiguration, + saved_query_class: Type[PydanticSavedQuery] = PydanticSavedQuery, ) -> FileParsingResult: """Parses transform config file passed as string - Returns list of model objects.""" - results: List[Union[PydanticSemanticModel, PydanticMetric, PydanticProjectConfiguration]] = [] + results: List[Union[PydanticSemanticModel, PydanticMetric, PydanticProjectConfiguration, PydanticSavedQuery]] = [] ctx: Optional[ParsingContext] = None issues: List[ValidationIssue] = [] try: @@ -322,6 +338,9 @@ def parse_config_yaml( elif document_type == PROJECT_CONFIGURATION_TYPE: project_configuration_validator.validate(config_document[document_type]) results.append(project_configuration_class.parse_obj(object_cfg)) + elif document_type == SAVED_QUERY_TYPE: + saved_query_validator.validate(config_document[document_type]) + results.append(saved_query_class.parse_obj(object_cfg)) else: issues.append( ValidationError( 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 ebc1bf4d..8010078c 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 @@ -391,6 +391,41 @@ ], "type": "object" }, + "saved_query_schema": { + "$id": "saved_query_schema", + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "group_bys": { + "items": { + "type": "string" + }, + "type": "array" + }, + "metrics": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "where": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "metrics" + ], + "type": "object" + }, "semantic_model_defaults_schema": { "$id": "semantic_model_defaults_schema", "additionalProperties": false, diff --git a/dbt_semantic_interfaces/parsing/schemas.py b/dbt_semantic_interfaces/parsing/schemas.py index efbc7914..935d6617 100644 --- a/dbt_semantic_interfaces/parsing/schemas.py +++ b/dbt_semantic_interfaces/parsing/schemas.py @@ -269,6 +269,29 @@ } +saved_query_schema = { + "$id": "saved_query_schema", + "type": "object", + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + "metrics": { + "type": "array", + "items": {"type": "string"}, + }, + "group_bys": { + "type": "array", + "items": {"type": "string"}, + }, + "where": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["name", "metrics"], + "additionalProperties": False, +} + semantic_model_schema = { "$id": "semantic_model_schema", "type": "object", @@ -297,6 +320,7 @@ metric_schema["$id"]: metric_schema, semantic_model_schema["$id"]: semantic_model_schema, project_configuration_schema["$id"]: project_configuration_schema, + saved_query_schema["$id"]: saved_query_schema, # Sub-object schemas metric_input_measure_schema["$id"]: metric_input_measure_schema, metric_type_params_schema["$id"]: metric_type_params_schema, @@ -318,3 +342,4 @@ semantic_model_validator = SchemaValidator(semantic_model_schema, resolver=resolver) metric_validator = SchemaValidator(metric_schema, resolver=resolver) project_configuration_validator = SchemaValidator(project_configuration_schema, resolver=resolver) +saved_query_validator = SchemaValidator(saved_query_schema, resolver=resolver) diff --git a/dbt_semantic_interfaces/protocols/__init__.py b/dbt_semantic_interfaces/protocols/__init__.py index 1392059a..45f4917e 100644 --- a/dbt_semantic_interfaces/protocols/__init__.py +++ b/dbt_semantic_interfaces/protocols/__init__.py @@ -18,6 +18,7 @@ MetricTypeParams, ) from dbt_semantic_interfaces.protocols.protocol_hint import ProtocolHint # noqa:F401 +from dbt_semantic_interfaces.protocols.saved_query import SavedQuery # noqa:F401 from dbt_semantic_interfaces.protocols.semantic_manifest import ( # noqa:F401 SemanticManifest, SemanticManifestT, diff --git a/dbt_semantic_interfaces/protocols/saved_query.py b/dbt_semantic_interfaces/protocols/saved_query.py new file mode 100644 index 00000000..0547ba74 --- /dev/null +++ b/dbt_semantic_interfaces/protocols/saved_query.py @@ -0,0 +1,39 @@ +from abc import abstractmethod +from typing import Optional, Protocol, Sequence + +from dbt_semantic_interfaces.protocols.metadata import Metadata +from dbt_semantic_interfaces.protocols.where_filter import WhereFilter + + +class SavedQuery(Protocol): + """Represents a query that the user wants to run repeatedly.""" + + @property + @abstractmethod + def metadata(self) -> Optional[Metadata]: # noqa: D + pass + + @property + @abstractmethod + def name(self) -> str: # noqa: D + pass + + @property + @abstractmethod + def description(self) -> Optional[str]: # noqa: D + pass + + @property + @abstractmethod + def metrics(self) -> Sequence[str]: # noqa: D + pass + + @property + @abstractmethod + def group_bys(self) -> Sequence[str]: # noqa: D + pass + + @property + @abstractmethod + def where(self) -> Sequence[WhereFilter]: # noqa: D + pass diff --git a/dbt_semantic_interfaces/protocols/semantic_manifest.py b/dbt_semantic_interfaces/protocols/semantic_manifest.py index 250b36ce..0b990961 100644 --- a/dbt_semantic_interfaces/protocols/semantic_manifest.py +++ b/dbt_semantic_interfaces/protocols/semantic_manifest.py @@ -3,6 +3,7 @@ from dbt_semantic_interfaces.protocols.metric import Metric from dbt_semantic_interfaces.protocols.project_configuration import ProjectConfiguration +from dbt_semantic_interfaces.protocols.saved_query import SavedQuery from dbt_semantic_interfaces.protocols.semantic_model import SemanticModel @@ -24,5 +25,9 @@ def metrics(self) -> Sequence[Metric]: # noqa: D def project_configuration(self) -> ProjectConfiguration: # noqa: D pass + @property + def saved_queries(self) -> Sequence[SavedQuery]: # noqa: D + pass + SemanticManifestT = TypeVar("SemanticManifestT", bound=SemanticManifest) diff --git a/dbt_semantic_interfaces/validations/primary_entity.py b/dbt_semantic_interfaces/validations/primary_entity.py index 56980858..3c408b84 100644 --- a/dbt_semantic_interfaces/validations/primary_entity.py +++ b/dbt_semantic_interfaces/validations/primary_entity.py @@ -1,9 +1,6 @@ import logging from typing import Generic, List, Sequence -from dbt_semantic_interfaces.implementations.semantic_manifest import ( - PydanticSemanticManifest, -) from dbt_semantic_interfaces.protocols import SemanticManifestT, SemanticModel from dbt_semantic_interfaces.references import SemanticModelReference from dbt_semantic_interfaces.type_enums import EntityType @@ -95,7 +92,7 @@ def _check_model(semantic_model: SemanticModel) -> Sequence[ValidationIssue]: @staticmethod @validate_safely("Check that semantic models in the manifest have properly configured primary entities.") - def validate_manifest(semantic_manifest: PydanticSemanticManifest) -> Sequence[ValidationIssue]: # noqa: D + def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[ValidationIssue]: # noqa: D issues: List[ValidationIssue] = [] for semantic_model in semantic_manifest.semantic_models: issues += PrimaryEntityRule._check_model(semantic_model) diff --git a/dbt_semantic_interfaces/validations/saved_query.py b/dbt_semantic_interfaces/validations/saved_query.py new file mode 100644 index 00000000..7ef85385 --- /dev/null +++ b/dbt_semantic_interfaces/validations/saved_query.py @@ -0,0 +1,146 @@ +import logging +import traceback +from typing import Generic, List, Sequence, Set + +from dbt_semantic_interfaces.call_parameter_sets import FilterCallParameterSets +from dbt_semantic_interfaces.naming.keywords import METRIC_TIME_ELEMENT_NAME +from dbt_semantic_interfaces.parsing.where_filter_parser import WhereFilterParser +from dbt_semantic_interfaces.protocols import SemanticManifestT +from dbt_semantic_interfaces.protocols.saved_query import SavedQuery +from dbt_semantic_interfaces.validations.validator_helpers import ( + FileContext, + SavedQueryContext, + SavedQueryElementType, + SemanticManifestValidationRule, + ValidationError, + ValidationIssue, + generate_exception_issue, + validate_safely, +) + +logger = logging.getLogger(__name__) + + +class SavedQueryRule(SemanticManifestValidationRule[SemanticManifestT], Generic[SemanticManifestT]): + """Validates fields in a saved query. + + As the semantic model graph is not traversed completely in DSI, the validations for saved queries can't be complete. + Consequently, the current plan is that we add a separate validation using MetricFlow in CI. + + * Check if metric names exist in the manifest. + * Check that the where filter is valid using the same logic as WhereFiltersAreParsable + """ + + @staticmethod + @validate_safely("Validate the group-by field in a saved query.") + def _check_group_bys(valid_group_by_element_names: Set[str], saved_query: SavedQuery) -> Sequence[ValidationIssue]: + issues: List[ValidationIssue] = [] + + for group_by_item in saved_query.group_bys: + # TODO: Replace with more appropriate abstractions once available. + parameter_sets: FilterCallParameterSets + try: + parameter_sets = WhereFilterParser.parse_call_parameter_sets("{{" + group_by_item + "}}") + except Exception as e: + issues.append( + generate_exception_issue( + what_was_being_done=f"trying to parse a group-by in saved query `{saved_query.name}`", + e=e, + context=SavedQueryContext( + file_context=FileContext.from_metadata(metadata=saved_query.metadata), + element_type=SavedQueryElementType.WHERE, + element_value=group_by_item, + ), + extras={ + "traceback": "".join(traceback.format_tb(e.__traceback__)), + }, + ) + ) + continue + + element_names_in_group_by = ( + [x.entity_reference.element_name for x in parameter_sets.entity_call_parameter_sets] + + [x.dimension_reference.element_name for x in parameter_sets.dimension_call_parameter_sets] + + [x.time_dimension_reference.element_name for x in parameter_sets.time_dimension_call_parameter_sets] + ) + + if len(element_names_in_group_by) != 1 or element_names_in_group_by[0] not in valid_group_by_element_names: + issues.append( + ValidationError( + message=f"`{group_by_item}` is not a valid group-by name.", + context=SavedQueryContext( + file_context=FileContext.from_metadata(metadata=saved_query.metadata), + element_type=SavedQueryElementType.GROUP_BY, + element_value=group_by_item, + ), + ) + ) + return issues + + @staticmethod + @validate_safely("Validate the metrics field in a saved query.") + def _check_metrics(valid_metric_names: Set[str], saved_query: SavedQuery) -> Sequence[ValidationIssue]: + issues: List[ValidationIssue] = [] + for metric_name in saved_query.metrics: + if metric_name not in valid_metric_names: + issues.append( + ValidationError( + message=f"`{metric_name}` is not a valid metric name.", + context=SavedQueryContext( + file_context=FileContext.from_metadata(metadata=saved_query.metadata), + element_type=SavedQueryElementType.METRIC, + element_value=metric_name, + ), + ) + ) + return issues + + @staticmethod + @validate_safely("Validate the where field in a saved query.") + def _check_where(saved_query: SavedQuery) -> Sequence[ValidationIssue]: + issues: List[ValidationIssue] = [] + for where_filter in saved_query.where: + try: + where_filter.call_parameter_sets + except Exception as e: + issues.append( + generate_exception_issue( + what_was_being_done=f"trying to parse a filter in saved query `{saved_query.name}`", + e=e, + context=SavedQueryContext( + file_context=FileContext.from_metadata(metadata=saved_query.metadata), + element_type=SavedQueryElementType.WHERE, + element_value=where_filter.where_sql_template, + ), + extras={ + "traceback": "".join(traceback.format_tb(e.__traceback__)), + }, + ) + ) + + return issues + + @staticmethod + @validate_safely("Validate all saved queries in a semantic manifest.") + def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[ValidationIssue]: # noqa: D + issues: List[ValidationIssue] = [] + valid_metric_names = {metric.name for metric in semantic_manifest.metrics} + valid_group_by_element_names = {METRIC_TIME_ELEMENT_NAME} + for semantic_model in semantic_manifest.semantic_models: + for dimension in semantic_model.dimensions: + valid_group_by_element_names.add(dimension.name) + for entity in semantic_model.entities: + valid_group_by_element_names.add(entity.name) + + for saved_query in semantic_manifest.saved_queries: + issues += SavedQueryRule._check_metrics( + valid_metric_names=valid_metric_names, + saved_query=saved_query, + ) + issues += SavedQueryRule._check_group_bys( + valid_group_by_element_names=valid_group_by_element_names, + saved_query=saved_query, + ) + issues += SavedQueryRule._check_where(saved_query=saved_query) + + return issues diff --git a/dbt_semantic_interfaces/validations/semantic_manifest_validator.py b/dbt_semantic_interfaces/validations/semantic_manifest_validator.py index 4caafb55..1eef9ffe 100644 --- a/dbt_semantic_interfaces/validations/semantic_manifest_validator.py +++ b/dbt_semantic_interfaces/validations/semantic_manifest_validator.py @@ -26,6 +26,7 @@ from dbt_semantic_interfaces.validations.non_empty import NonEmptyRule from dbt_semantic_interfaces.validations.primary_entity import PrimaryEntityRule from dbt_semantic_interfaces.validations.reserved_keywords import ReservedKeywordsRule +from dbt_semantic_interfaces.validations.saved_query import SavedQueryRule from dbt_semantic_interfaces.validations.semantic_models import ( SemanticModelDefaultsRule, SemanticModelValidityWindowRule, @@ -79,6 +80,7 @@ class SemanticManifestValidator(Generic[SemanticManifestT]): PrimaryEntityRule[SemanticManifestT](), PrimaryEntityDimensionPairs[SemanticManifestT](), WhereFiltersAreParseable[SemanticManifestT](), + SavedQueryRule[SemanticManifestT](), ) def __init__( diff --git a/dbt_semantic_interfaces/validations/validator_helpers.py b/dbt_semantic_interfaces/validations/validator_helpers.py index 8bea186f..ade1ff35 100644 --- a/dbt_semantic_interfaces/validations/validator_helpers.py +++ b/dbt_semantic_interfaces/validations/validator_helpers.py @@ -135,11 +135,35 @@ def context_str(self) -> str: ) +class SavedQueryElementType(Enum): + """Maps the fields in a saved query to a readable string.""" + + METRIC = "metric" + GROUP_BY = "group by" + WHERE = "where" + + +class SavedQueryContext(BaseModel): + """Provides context on where a saved query was defined.""" + + file_context: FileContext + element_type: SavedQueryElementType + element_value: str + + def context_str(self) -> str: + """Human-readable stringified representation of the context.""" + return ( + f"with a {self.element_type.value} in saved query `{self.element_type.value}` " + f"{self.file_context.context_str()}" + ) + + ValidationContext = Union[ FileContext, MetricContext, SemanticModelContext, SemanticModelElementContext, + SavedQueryContext, ] diff --git a/pyproject.toml b/pyproject.toml index c9164448..939abae9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,9 @@ ignore = [ # Missing docstring in public package -- often docs handled within files not __init__.py "D104" ] +# Let ruff autofix these errors. +# F401 - Unused imports. +fixable = ["F401"] [tool.ruff.pydocstyle] convention = "google" diff --git a/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/saved_queries.yaml b/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/saved_queries.yaml new file mode 100644 index 00000000..e822550f --- /dev/null +++ b/tests/fixtures/semantic_manifest_yamls/simple_semantic_manifest/saved_queries.yaml @@ -0,0 +1,12 @@ +--- +saved_query: + name: p0_booking + description: Booking-related metrics that are of the highest priority. + metrics: + - bookings + - instant_bookings + group_bys: + - TimeDimension('metric_time', 'DAY') + - Dimension('listing__capacity_latest') + where: + - "{{ Dimension('listing__capacity_latest') }} > 3" diff --git a/tests/validations/test_entities.py b/tests/validations/test_entities.py index 2457a78f..18320644 100644 --- a/tests/validations/test_entities.py +++ b/tests/validations/test_entities.py @@ -47,7 +47,7 @@ def func(semantic_model: PydanticSemanticModel) -> bool: entity_references.add(entity.reference) model_issues = SemanticManifestValidator[PydanticSemanticManifest]( - [PrimaryEntityRule()] + [PrimaryEntityRule[PydanticSemanticManifest]()] ).validate_semantic_manifest(model) expected_issue_message = ( diff --git a/tests/validations/test_saved_query.py b/tests/validations/test_saved_query.py new file mode 100644 index 00000000..c6ae46f4 --- /dev/null +++ b/tests/validations/test_saved_query.py @@ -0,0 +1,117 @@ +import copy +import logging + +from dbt_semantic_interfaces.implementations.filters.where_filter import ( + PydanticWhereFilter, +) +from dbt_semantic_interfaces.implementations.saved_query import PydanticSavedQuery +from dbt_semantic_interfaces.implementations.semantic_manifest import ( + PydanticSemanticManifest, +) +from dbt_semantic_interfaces.validations.saved_query import SavedQueryRule +from dbt_semantic_interfaces.validations.semantic_manifest_validator import ( + SemanticManifestValidator, +) +from dbt_semantic_interfaces.validations.validator_helpers import ( + SemanticManifestValidationResults, +) + +logger = logging.getLogger(__name__) + + +def check_only_one_error_with_message( # noqa: D + results: SemanticManifestValidationResults, target_message: str +) -> None: + assert len(results.warnings) == 0 + assert len(results.errors) == 1 + assert len(results.future_errors) == 0 + + found_match = results.errors[0].message.find(target_message) != -1 + # Adding this dict to the assert so that when it does not match, pytest prints the expected and actual values. + assert { + "expected": target_message, + "actual": results.errors[0].message, + } and found_match + + +def test_invalid_metric_in_saved_query( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = copy.deepcopy(simple_semantic_manifest__with_primary_transforms) + manifest.saved_queries = [ + PydanticSavedQuery( + name="Example Saved Query", + description="Example description.", + metrics=["invalid_metric"], + group_bys=["Dimension('booking__is_instant')"], + where=[PydanticWhereFilter(where_sql_template="{{ Dimension('booking__is_instant') }}")], + ), + ] + + manifest_validator = SemanticManifestValidator[PydanticSemanticManifest]([SavedQueryRule()]) + check_only_one_error_with_message( + manifest_validator.validate_semantic_manifest(manifest), "is not a valid metric name." + ) + + +def test_invalid_where_in_saved_query( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = copy.deepcopy(simple_semantic_manifest__with_primary_transforms) + manifest.saved_queries = [ + PydanticSavedQuery( + name="Example Saved Query", + description="Example description.", + metrics=["bookings"], + group_bys=["Dimension('booking__is_instant')"], + where=[PydanticWhereFilter(where_sql_template="{{ invalid_jinja }}")], + ), + ] + + manifest_validator = SemanticManifestValidator[PydanticSemanticManifest]([SavedQueryRule()]) + check_only_one_error_with_message( + manifest_validator.validate_semantic_manifest(manifest), + "trying to parse a filter in saved query", + ) + + +def test_invalid_group_by_element_in_saved_query( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = copy.deepcopy(simple_semantic_manifest__with_primary_transforms) + manifest.saved_queries = [ + PydanticSavedQuery( + name="Example Saved Query", + description="Example description.", + metrics=["bookings"], + group_bys=["Dimension('booking__invalid_dimension')"], + where=[PydanticWhereFilter(where_sql_template="{{ Dimension('booking__is_instant') }}")], + ), + ] + + manifest_validator = SemanticManifestValidator[PydanticSemanticManifest]([SavedQueryRule()]) + check_only_one_error_with_message( + manifest_validator.validate_semantic_manifest(manifest), + "is not a valid group-by name.", + ) + + +def test_invalid_group_by_format_in_saved_query( # noqa: D + simple_semantic_manifest__with_primary_transforms: PydanticSemanticManifest, +) -> None: + manifest = copy.deepcopy(simple_semantic_manifest__with_primary_transforms) + manifest.saved_queries = [ + PydanticSavedQuery( + name="Example Saved Query", + description="Example description.", + metrics=["bookings"], + group_bys=["invalid_format"], + where=[PydanticWhereFilter(where_sql_template="{{ Dimension('booking__is_instant') }}")], + ), + ] + + manifest_validator = SemanticManifestValidator[PydanticSemanticManifest]([SavedQueryRule()]) + check_only_one_error_with_message( + manifest_validator.validate_semantic_manifest(manifest), + "An error occurred while trying to parse a group-by in saved query", + )