Repository: dbt-labs/dbt-core

Issue:
Saved queries should always include relation spec (`database.schema.alias`), and respect `generate_x_name` macros
### We should:
- always include `NodeRelation` objects for each export (including `database`, `schema_name`, `alias`)
- resolve the `database` / `schema_name` / `alias` with respect to `generate_x_name` macros (possibly by calling [this method](https://github.com/dbt-labs/dbt-core/blob/15dcb9a19d7549dbb5c4dbf250f6d915578cbfe9/core/dbt/parser/base.py#L285))

### Testing Considerations:

Will need a migration from previously saved manifests. 
Needs to be non-breaking change to artifacts.

### Considerations:
May not be able to backport these.

### Discussion Summarization:
* Might need to refactor `generate_x_name` functions to be usable outside of initial parsing
* Might not be able to _migrate_ existing manifests, i.e. they won't get regenerated until reparsed

Saved queries should always include relation spec (`database.schema.alias`), and respect `generate_x_name` macros
### We should:
- always include `NodeRelation` objects for each export (including `database`, `schema_name`, `alias`)
- resolve the `database` / `schema_name` / `alias` with respect to `generate_x_name` macros (possibly by calling [this method](https://github.com/dbt-labs/dbt-core/blob/15dcb9a19d7549dbb5c4dbf250f6d915578cbfe9/core/dbt/parser/base.py#L285))

### Testing Considerations:

Will need a migration from previously saved manifests. 
Needs to be non-breaking change to artifacts.

### Considerations:
May not be able to backport these.

### Discussion Summarization:
* Might need to refactor `generate_x_name` functions to be usable outside of initial parsing
* Might not be able to _migrate_ existing manifests, i.e. they won't get regenerated until reparsed



Discussion:
Proposal that this is not required for 1.8
Update from estimation discussion:
1. We can potentially avoid modifying artifacts by only adding the `NodeRelation` object to the export internal object that is not written to disk.
2. Investigate whether this approach will satisfy A/C of resolving the `database` / `schema_name` / `alias` regarding `generate_x_name` macros.
3. Document feasibility of writing `NodeRelation` to artifacts if required.
Proposal that this is not required for 1.8
Update from estimation discussion:
1. We can potentially avoid modifying artifacts by only adding the `NodeRelation` object to the export internal object that is not written to disk.
2. Investigate whether this approach will satisfy A/C of resolving the `database` / `schema_name` / `alias` regarding `generate_x_name` macros.
3. Document feasibility of writing `NodeRelation` to artifacts if required.

Resolving patch:
diff --git a/core/dbt/artifacts/resources/v1/saved_query.py b/core/dbt/artifacts/resources/v1/saved_query.py
--- a/core/dbt/artifacts/resources/v1/saved_query.py
+++ b/core/dbt/artifacts/resources/v1/saved_query.py
@@ -21,6 +21,7 @@ class ExportConfig(dbtClassMixin):
     export_as: ExportDestinationType
     schema_name: Optional[str] = None
     alias: Optional[str] = None
+    database: Optional[str] = None
 
 
 @dataclass
diff --git a/core/dbt/parser/base.py b/core/dbt/parser/base.py
--- a/core/dbt/parser/base.py
+++ b/core/dbt/parser/base.py
@@ -109,7 +109,7 @@ def __init__(self, config: RuntimeConfig, manifest: Manifest, component: str) ->
         self.component = component
 
     def __call__(self, parsed_node: Any, override: Optional[str]) -> None:
-        if parsed_node.package_name in self.package_updaters:
+        if getattr(parsed_node, "package_name", None) in self.package_updaters:
             new_value = self.package_updaters[parsed_node.package_name](override, parsed_node)
         else:
             new_value = self.default_updater(override, parsed_node)
@@ -293,7 +293,7 @@ def update_parsed_node_relation_names(
         self._update_node_alias(parsed_node, config_dict.get("alias"))
 
         # Snapshot nodes use special "target_database" and "target_schema" fields for some reason
-        if parsed_node.resource_type == NodeType.Snapshot:
+        if getattr(parsed_node, "resource_type", None) == NodeType.Snapshot:
             if "target_database" in config_dict and config_dict["target_database"]:
                 parsed_node.database = config_dict["target_database"]
             if "target_schema" in config_dict and config_dict["target_schema"]:
@@ -452,7 +452,7 @@ def _update_node_relation_name(self, node: ManifestNode):
         # and TestNodes that store_failures.
         # TestNodes do not get a relation_name without store failures
         # because no schema is created.
-        if node.is_relational and not node.is_ephemeral_model:
+        if getattr(node, "is_relational", None) and not getattr(node, "is_ephemeral_model", None):
             adapter = get_adapter(self.root_project)
             relation_cls = adapter.Relation
             node.relation_name = str(relation_cls.create_from(self.root_project, node))
diff --git a/core/dbt/parser/schema_yaml_readers.py b/core/dbt/parser/schema_yaml_readers.py
--- a/core/dbt/parser/schema_yaml_readers.py
+++ b/core/dbt/parser/schema_yaml_readers.py
@@ -764,6 +764,22 @@ def parse_saved_query(self, unparsed: UnparsedSavedQuery) -> None:
             group=config.group,
         )
 
+        for export in parsed.exports:
+            self.schema_parser.update_parsed_node_relation_names(export, export.config.to_dict())  # type: ignore
+
+            if not export.config.schema_name:
+                export.config.schema_name = getattr(export, "schema", None)
+            delattr(export, "schema")
+
+            export.config.database = getattr(export, "database", None) or export.config.database
+            delattr(export, "database")
+
+            if not export.config.alias:
+                export.config.alias = getattr(export, "alias", None)
+            delattr(export, "alias")
+
+            delattr(export, "relation_name")
+
         # Only add thes saved query if it's enabled, otherwise we track it with other diabled nodes
         if parsed.config.enabled:
             self.manifest.add_saved_query(self.yaml.file, parsed)


Test patch:
diff --git a/tests/functional/saved_queries/fixtures.py b/tests/functional/saved_queries/fixtures.py
--- a/tests/functional/saved_queries/fixtures.py
+++ b/tests/functional/saved_queries/fixtures.py
@@ -26,6 +26,29 @@
             schema: my_export_schema_name
 """
 
+saved_queries_with_defaults_yml = """
+version: 2
+
+saved_queries:
+  - name: test_saved_query
+    description: "{{ doc('saved_query_description') }}"
+    label: Test Saved Query
+    query_params:
+        metrics:
+            - simple_metric
+        group_by:
+            - "Dimension('user__ds')"
+        where:
+            - "{{ Dimension('user__ds', 'DAY') }} <= now()"
+            - "{{ Dimension('user__ds', 'DAY') }} >= '2023-01-01'"
+            - "{{ Metric('txn_revenue', ['id']) }} > 1"
+    exports:
+        - name: my_export
+          config:
+            alias: my_export_alias
+            export_as: table
+"""
+
 saved_queries_with_diff_filters_yml = """
 version: 2
 
diff --git a/tests/functional/saved_queries/test_configs.py b/tests/functional/saved_queries/test_configs.py
--- a/tests/functional/saved_queries/test_configs.py
+++ b/tests/functional/saved_queries/test_configs.py
@@ -12,6 +12,7 @@
     saved_query_with_extra_config_attributes_yml,
     saved_query_with_export_configs_defined_at_saved_query_level_yml,
     saved_query_without_export_configs_defined_yml,
+    saved_queries_with_defaults_yml,
 )
 from tests.functional.semantic_models.fixtures import (
     fct_revenue_sql,
@@ -121,6 +122,33 @@ def test_extra_config_properties_dont_break_parsing(self, project):
         assert saved_query.exports[0].config.__dict__.get("my_random_config") is None
 
 
+class TestExportConfigsWithDefaultProperties(BaseConfigProject):
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "saved_queries.yml": saved_queries_with_defaults_yml,
+            "schema.yml": schema_yml,
+            "fct_revenue.sql": fct_revenue_sql,
+            "metricflow_time_spine.sql": metricflow_time_spine_sql,
+            "docs.md": saved_query_description,
+        }
+
+    def test_default_properties(self, project):
+        runner = dbtTestRunner()
+
+        # parse with default fixture project config
+        result = runner.invoke(["parse"])
+        assert result.success
+        assert isinstance(result.result, Manifest)
+        assert len(result.result.saved_queries) == 1
+        saved_query = result.result.saved_queries["saved_query.test.test_saved_query"]
+        assert len(saved_query.exports) == 1
+        export = saved_query.exports[0]
+        assert export.config.alias == "my_export_alias"
+        assert export.config.schema_name == project.test_schema
+        assert export.config.database == project.database
+
+
 class TestInheritingExportConfigFromSavedQueryConfig(BaseConfigProject):
     @pytest.fixture(scope="class")
     def models(self):
@@ -152,6 +180,7 @@ def test_export_config_inherits_from_saved_query(self, project):
         assert export1.config.export_as != saved_query.config.export_as
         assert export1.config.schema_name == "my_custom_export_schema"
         assert export1.config.schema_name != saved_query.config.schema
+        assert export1.config.database == project.database
 
         # assert Export `my_export` has its configs defined from the saved_query because they should take priority
         export2 = next(
@@ -162,6 +191,7 @@ def test_export_config_inherits_from_saved_query(self, project):
         assert export2.config.export_as == saved_query.config.export_as
         assert export2.config.schema_name == "my_default_export_schema"
         assert export2.config.schema_name == saved_query.config.schema
+        assert export2.config.database == project.database
 
 
 class TestInheritingExportConfigsFromProject(BaseConfigProject):