diff --git a/dbt_semantic_interfaces/implementations/base.py b/dbt_semantic_interfaces/implementations/base.py index 4a016fec..2ec02baa 100644 --- a/dbt_semantic_interfaces/implementations/base.py +++ b/dbt_semantic_interfaces/implementations/base.py @@ -151,12 +151,18 @@ def __parse_with_custom_handling( to the caller to be pre-validated, and so we do not bother guarding against that here. """ if isinstance(input, dict): - return cls(**input) # type: ignore + return cls._pre_parse_dict_input(input) elif isinstance(input, cls): return input else: return cls._from_yaml_value(input) + @classmethod + def _pre_parse_dict_input( + cls: Type[PydanticCustomInputParser[ModelObjectT_co]], input: dict + ) -> PydanticCustomInputParser[ModelObjectT_co]: + return cls(**input) # type: ignore + @classmethod @abstractmethod def _from_yaml_value( diff --git a/dbt_semantic_interfaces/implementations/saved_query.py b/dbt_semantic_interfaces/implementations/saved_query.py index 0c6da416..e5a024db 100644 --- a/dbt_semantic_interfaces/implementations/saved_query.py +++ b/dbt_semantic_interfaces/implementations/saved_query.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import List, Optional +from typing import Any, List, Optional, Union -from typing_extensions import override +from typing_extensions import Self, override from dbt_semantic_interfaces.implementations.base import ( HashableBaseModel, @@ -20,6 +20,10 @@ ) from dsi_pydantic_shim import Field +# Type alias for the implicit "Any" type used as input and output for Pydantic's parsing API +PydanticParseableValueType = Any # type: ignore[misc] +# ModelObjectT_co = TypeVar("ModelObjectT_co", covariant=True, bound=BaseModel) + class PydanticSavedQueryQueryParams(HashableBaseModel, ProtocolHint[SavedQueryQueryParams]): """Pydantic implementation of SavedQuery.""" @@ -35,7 +39,12 @@ def _implements_protocol(self) -> SavedQueryQueryParams: where: Optional[PydanticWhereFilterIntersection] = None -class PydanticSavedQuery(HashableBaseModel, ModelWithMetadataParsing, ProtocolHint[SavedQuery]): +class PydanticSavedQuery( + # PydanticCustomInputParser, + HashableBaseModel, + ModelWithMetadataParsing, + ProtocolHint[SavedQuery], +): """Pydantic implementation of SavedQuery.""" @override @@ -48,3 +57,15 @@ def _implements_protocol(self) -> SavedQuery: metadata: Optional[PydanticMetadata] = None label: Optional[str] = None exports: List[PydanticExport] = Field(default_factory=list) + tags: Union[str, List[str]] = Field( + default_factory=list, + ) + + @classmethod + def parse_obj(cls, input: Any) -> Self: # noqa + if isinstance(input, dict): + if isinstance(input.get("tags"), str): + input["tags"] = [input["tags"]] + if isinstance(input.get("tags"), list): + input["tags"].sort() + return super(HashableBaseModel, cls).parse_obj(input) 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 5e247886..ee6b5631 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 @@ -705,6 +705,19 @@ }, "query_params": { "$ref": "#/definitions/saved_query_query_params_schema" + }, + "tags": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] } }, "required": [ diff --git a/dbt_semantic_interfaces/parsing/schemas.py b/dbt_semantic_interfaces/parsing/schemas.py index 21ff8d3e..f37daa60 100644 --- a/dbt_semantic_interfaces/parsing/schemas.py +++ b/dbt_semantic_interfaces/parsing/schemas.py @@ -489,6 +489,15 @@ "query_params": {"$ref": "saved_query_query_params_schema"}, "label": {"type": "string"}, "exports": {"type": "array", "items": {"$ref": "export_schema"}}, + "tags": { + "oneOf": [ + {"type": "string"}, + { + "type": "array", + "items": {"type": "string"}, + }, + ], + }, }, "required": ["name", "query_params"], "additionalProperties": False, diff --git a/dbt_semantic_interfaces/protocols/saved_query.py b/dbt_semantic_interfaces/protocols/saved_query.py index 6f02866e..a8e33ce7 100644 --- a/dbt_semantic_interfaces/protocols/saved_query.py +++ b/dbt_semantic_interfaces/protocols/saved_query.py @@ -73,3 +73,9 @@ def label(self) -> Optional[str]: def exports(self) -> Sequence[Export]: """Exports that can run using this saved query.""" pass + + @property + @abstractmethod + def tags(self) -> Sequence[str]: + """List of tags to be used as part of resource selection in dbt.""" + pass diff --git a/tests/parsing/test_saved_query_parsing.py b/tests/parsing/test_saved_query_parsing.py index 2bc04d11..292f1b5d 100644 --- a/tests/parsing/test_saved_query_parsing.py +++ b/tests/parsing/test_saved_query_parsing.py @@ -99,6 +99,9 @@ def test_saved_query_group_by() -> None: """\ saved_query: name: test_saved_query_group_bys + tags: + - "tag_1" + - "tag_2" query_params: metrics: - test_metric_a @@ -173,6 +176,118 @@ def test_saved_query_where() -> None: assert where == saved_query.query_params.where.where_filters[0].where_sql_template +def test_saved_query_with_single_tag_string() -> None: + """Test for parsing a single string (not a list) tag in a saved query.""" + yaml_contents = textwrap.dedent( + """\ + saved_query: + name: test_saved_query_group_bys + tags: "tag_1" + query_params: + metrics: + - test_metric_a + """ + ) + 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.tags is not None + assert len(saved_query.tags) == 1 + assert saved_query.tags == ["tag_1"] + + +def test_saved_query_with_multiline_list_of_tags() -> None: + """Test for parsing a multiline list of tags in a saved query.""" + yaml_contents = textwrap.dedent( + """\ + saved_query: + name: test_saved_query_group_bys + tags: ["tag_1", "tag_2"] + query_params: + metrics: + - test_metric_a + """ + ) + 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.tags is not None + assert len(saved_query.tags) == 2 + assert saved_query.tags == ["tag_1", "tag_2"] + + +def test_saved_query_with_single_line_list_of_tags() -> None: + """Test for parsing a single-line list of tags in a saved query.""" + yaml_contents = textwrap.dedent( + """\ + saved_query: + name: test_saved_query_group_bys + tags: + - "tag_1" + - "tag_2" + query_params: + metrics: + - test_metric_a + """ + ) + 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.tags is not None + assert len(saved_query.tags) == 2 + assert saved_query.tags == ["tag_1", "tag_2"] + + +def test_saved_query_tags_are_sorted() -> None: + """Test tags in a saved query are SORTED after parsing.""" + yaml_contents = textwrap.dedent( + """\ + saved_query: + name: test_saved_query_group_bys + tags: + - "tag_2" + - "tag_1" + query_params: + metrics: + - test_metric_a + """ + ) + 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.tags is not None + assert len(saved_query.tags) == 2 + assert saved_query.tags == ["tag_1", "tag_2"] + + +def test_saved_query_with_no_tags_defaults_to_empty_list() -> None: + """Test tags in a saved query will default to empty list if missing.""" + yaml_contents = textwrap.dedent( + """\ + saved_query: + name: test_saved_query_group_bys + query_params: + metrics: + - test_metric_a + """ + ) + 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.tags is not None + assert saved_query.tags == [] + + def test_saved_query_exports() -> None: """Test for parsing exports referenced in a saved query.""" yaml_contents = textwrap.dedent(