Repository: dbt-labs/dbt-core

Issue:
Support dots in model names
### Describe the bug
In our dbt project we are managing multiple schemas. We are namespacing the models with the standard SQL syntax `<schema>.<relation>.sql` and following [this approach](https://discourse.getdbt.com/t/extracting-schema-and-model-names-from-the-filename/575). It works quite well for the most part, however when specifying model selectors by FQN (`dbt run --model myschema.mytable`) the matcher is unable to locate the desired models

### Steps To Reproduce
Using a model or seed with dots in their name and trying to run it via FQN selector (`dbt run --model model.with.dots`) will trigger the issue.

### Expected behavior
`model.with.dots` runs successfully

### Screenshots and log output
Log output is:
```
The selector 'model.with.dots' does not match any nodes and will be ignored
```

### System information
**Which database are you using dbt with?**
- [ ] postgres
- [ ] redshift
- [ ] bigquery
- [x] snowflake
- [ ] other (specify: ____________)


**The output of `dbt --version`:**
```
installed version: 0.19.1
   latest version: 0.19.1

Up to date!

Plugins:
  - bigquery: 0.19.1
  - postgres: 0.19.1
  - redshift: 0.19.1
  - snowflake: 0.19.1

```

**The operating system you're using:**
```
OS Name: Microsoft Windows 10 Enterprise
OS Version: 10.0.18363 N/A Build 18363
```
**The output of `python --version`:**
`Python 3.6.13 :: Anaconda, Inc.`

### Additional context
Related to #1269 



Discussion:
Made a PR to solve it #3247

Resolving patch:
diff --git a/core/dbt/graph/selector_methods.py b/core/dbt/graph/selector_methods.py
--- a/core/dbt/graph/selector_methods.py
+++ b/core/dbt/graph/selector_methods.py
@@ -49,25 +49,23 @@ class MethodName(StrEnum):
     Exposure = 'exposure'
 
 
-def is_selected_node(real_node, node_selector):
-    for i, selector_part in enumerate(node_selector):
-
-        is_last = (i == len(node_selector) - 1)
+def is_selected_node(fqn: List[str], node_selector: str):
+
+    # If qualified_name exactly matches model name (fqn's leaf), return True
+    if fqn[-1] == node_selector:
+        return True
+    # Flatten node parts. Dots in model names act as namespace separators
+    flat_fqn = [item for segment in fqn for item in segment.split('.')]
+    # Selector components cannot be more than fqn's
+    if len(flat_fqn) < len(node_selector.split('.')):
+        return False
 
+    for i, selector_part in enumerate(node_selector.split('.')):
         # if we hit a GLOB, then this node is selected
         if selector_part == SELECTOR_GLOB:
             return True
-
-        # match package.node_name or package.dir.node_name
-        elif is_last and selector_part == real_node[-1]:
-            return True
-
-        elif len(real_node) <= i:
-            return False
-
-        elif real_node[i] == selector_part:
+        elif flat_fqn[i] == selector_part:
             continue
-
         else:
             return False
 
@@ -154,31 +152,20 @@ def search(
 
 
 class QualifiedNameSelectorMethod(SelectorMethod):
-    def node_is_match(
-        self,
-        qualified_name: List[str],
-        package_names: Set[str],
-        fqn: List[str],
-    ) -> bool:
-        """Determine if a qualfied name matches an fqn, given the set of package
+    def node_is_match(self, qualified_name: str, fqn: List[str]) -> bool:
+        """Determine if a qualified name matches an fqn for all package
         names in the graph.
 
-        :param List[str] qualified_name: The components of the selector or node
-            name, split on '.'.
-        :param Set[str] package_names: The set of pacakge names in the graph.
+        :param str qualified_name: The qualified name to match the nodes with
         :param List[str] fqn: The node's fully qualified name in the graph.
         """
-        if len(qualified_name) == 1 and fqn[-1] == qualified_name[0]:
-            return True
+        unscoped_fqn = fqn[1:]
 
-        if qualified_name[0] in package_names:
-            if is_selected_node(fqn, qualified_name):
-                return True
-
-        for package_name in package_names:
-            local_qualified_node_name = [package_name] + qualified_name
-            if is_selected_node(fqn, local_qualified_node_name):
-                return True
+        if is_selected_node(fqn, qualified_name):
+            return True
+        # Match nodes across different packages
+        elif is_selected_node(unscoped_fqn, qualified_name):
+            return True
 
         return False
 
@@ -189,15 +176,9 @@ def search(
 
         :param str selector: The selector or node name
         """
-        qualified_name = selector.split(".")
         parsed_nodes = list(self.parsed_nodes(included_nodes))
-        package_names = {n.package_name for _, n in parsed_nodes}
         for node, real_node in parsed_nodes:
-            if self.node_is_match(
-                qualified_name,
-                package_names,
-                real_node.fqn,
-            ):
+            if self.node_is_match(selector, real_node.fqn):
                 yield node
 
 


Test patch:
diff --git a/test/integration/005_simple_seed_test/data-dottedseed/seed.with.dots.csv b/test/integration/005_simple_seed_test/data-dottedseed/seed.with.dots.csv
new file mode 100644
--- /dev/null
+++ b/test/integration/005_simple_seed_test/data-dottedseed/seed.with.dots.csv
@@ -0,0 +1,2 @@
+id
+1
diff --git a/test/integration/005_simple_seed_test/test_simple_seed.py b/test/integration/005_simple_seed_test/test_simple_seed.py
--- a/test/integration/005_simple_seed_test/test_simple_seed.py
+++ b/test/integration/005_simple_seed_test/test_simple_seed.py
@@ -286,3 +286,29 @@ def project_config(self):
     def test_postgres_simple_seed(self):
         results = self.run_dbt(["seed"])
         self.assertEqual(len(results),  1)
+
+
+class TestSimpleSeedWithDots(DBTIntegrationTest):
+
+    @property
+    def schema(self):
+        return "simple_seed_005"
+
+    @property
+    def models(self):
+        return "models"
+
+    @property
+    def project_config(self):
+        return {
+            'config-version': 2,
+            "data-paths": ['data-dottedseed'],
+            'seeds': {
+                'quote_columns': False,
+            }
+        }
+
+    @use_profile('postgres')
+    def test_postgres_simple_seed(self):
+        results = self.run_dbt(["seed"])
+        self.assertEqual(len(results),  1)
\ No newline at end of file
diff --git a/test/integration/007_graph_selection_tests/models/alternative.users.sql b/test/integration/007_graph_selection_tests/models/alternative.users.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/007_graph_selection_tests/models/alternative.users.sql
@@ -0,0 +1,9 @@
+{# Same as ´users´ model, but with dots in the model name #}
+{{
+    config(
+        materialized = 'table',
+        tags=['dots']
+    )
+}}
+
+select * from {{ ref('base_users') }}
diff --git a/test/integration/007_graph_selection_tests/test_graph_selection.py b/test/integration/007_graph_selection_tests/test_graph_selection.py
--- a/test/integration/007_graph_selection_tests/test_graph_selection.py
+++ b/test/integration/007_graph_selection_tests/test_graph_selection.py
@@ -50,6 +50,7 @@ def test__postgres__specific_model(self):
         self.assertTablesEqual("seed", "users")
         created_models = self.get_models_in_schema()
         self.assertFalse('users_rollup' in created_models)
+        self.assertFalse('alternative.users' in created_models)
         self.assertFalse('base_users' in created_models)
         self.assertFalse('emails' in created_models)
         self.assert_correct_schemas()
@@ -62,6 +63,7 @@ def test__postgres__tags(self):
         self.assertEqual(len(results), 2)
 
         created_models = self.get_models_in_schema()
+        self.assertFalse('alternative.users' in created_models)
         self.assertFalse('base_users' in created_models)
         self.assertFalse('emails' in created_models)
         self.assertTrue('users' in created_models)
@@ -79,7 +81,7 @@ def test__postgres__tags_and_children(self):
         self.run_sql_file("seed.sql")
 
         results = self.run_dbt(['run', '--models', 'tag:base+'])
-        self.assertEqual(len(results), 4)
+        self.assertEqual(len(results), 5)
 
         created_models = self.get_models_in_schema()
         self.assertFalse('base_users' in created_models)
@@ -87,6 +89,7 @@ def test__postgres__tags_and_children(self):
         self.assertIn('emails_alt', created_models)
         self.assertTrue('users_rollup' in created_models)
         self.assertTrue('users' in created_models)
+        self.assertTrue('alternative.users' in created_models)
         self.assert_correct_schemas()
 
     @use_profile('postgres')
@@ -94,7 +97,7 @@ def test__postgres__tags_and_children_limited(self):
         self.run_sql_file("seed.sql")
 
         results = self.run_dbt(['run', '--models', 'tag:base+2'])
-        self.assertEqual(len(results), 3)
+        self.assertEqual(len(results), 4)
 
         created_models = self.get_models_in_schema()
         self.assertFalse('base_users' in created_models)
@@ -102,6 +105,7 @@ def test__postgres__tags_and_children_limited(self):
         self.assertIn('emails_alt', created_models)
         self.assertIn('users_rollup', created_models)
         self.assertIn('users', created_models)
+        self.assertIn('alternative.users', created_models)
         self.assertNotIn('users_rollup_dependency', created_models)
         self.assert_correct_schemas()
 
@@ -131,6 +135,7 @@ def test__postgres__specific_model_and_children(self):
         created_models = self.get_models_in_schema()
         self.assertIn('emails_alt', created_models)
         self.assertNotIn('base_users', created_models)
+        self.assertNotIn('alternative.users', created_models)
         self.assertNotIn('emails', created_models)
         self.assert_correct_schemas()
 
@@ -264,16 +269,34 @@ def test__postgres__locally_qualified_name(self):
         self.assertIn('nested_users', created_models)
         self.assert_correct_schemas()
 
+    @use_profile('postgres')
+    def test__postgres__locally_qualified_name_model_with_dots(self):
+        self.run_sql_file("seed.sql")
+        results = self.run_dbt(['run', '--models', 'alternative.users'])
+        self.assertEqual(len(results), 1)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('alternative.users', created_models)
+        self.assert_correct_schemas()
+
+        results = self.run_dbt(['run', '--models', 'models/alternative.*'])
+        self.assertEqual(len(results), 1)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('alternative.users', created_models)
+        self.assert_correct_schemas()
+
     @use_profile('postgres')
     def test__postgres__childrens_parents(self):
         self.run_sql_file("seed.sql")
         results = self.run_dbt(['run', '--models', '@base_users'])
-        self.assertEqual(len(results), 4)
+        self.assertEqual(len(results), 5)
 
         created_models = self.get_models_in_schema()
         self.assertIn('users_rollup', created_models)
         self.assertIn('users', created_models)
         self.assertIn('emails_alt', created_models)
+        self.assertIn('alternative.users', created_models)
         self.assertNotIn('subdir', created_models)
         self.assertNotIn('nested_users', created_models)
 
diff --git a/test/integration/007_graph_selection_tests/test_schema_test_graph_selection.py b/test/integration/007_graph_selection_tests/test_schema_test_graph_selection.py
--- a/test/integration/007_graph_selection_tests/test_schema_test_graph_selection.py
+++ b/test/integration/007_graph_selection_tests/test_schema_test_graph_selection.py
@@ -28,7 +28,7 @@ def run_schema_and_assert(self, include, exclude, expected_tests):
         self.run_sql_file("seed.sql")
         self.run_dbt(["deps"])
         results = self.run_dbt(['run', '--exclude', 'never_selected'])
-        self.assertEqual(len(results), 9)
+        self.assertEqual(len(results), 10)
 
         args = FakeArgs()
         args.models = include
diff --git a/test/integration/009_data_tests_test/tests/my_db.my_schema.table_copy.pass_id_not_null.sql b/test/integration/009_data_tests_test/tests/my_db.my_schema.table_copy.pass_id_not_null.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/009_data_tests_test/tests/my_db.my_schema.table_copy.pass_id_not_null.sql
@@ -0,0 +1,5 @@
+{# Same as `pass_id_not_null` but with dots in its name #}
+
+select *
+from {{ ref('table_copy') }}
+where id is null
diff --git a/test/integration/024_custom_schema_test/custom-macros-multischema/multi_schema_macros.sql b/test/integration/024_custom_schema_test/custom-macros-multischema/multi_schema_macros.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/024_custom_schema_test/custom-macros-multischema/multi_schema_macros.sql
@@ -0,0 +1,20 @@
+
+{% macro generate_alias_name(custom_alias_name=none, node=none) -%}
+    {%- set node_name = node.name | trim -%}
+    {%- set split_name = node_name.split('.') -%}
+    {%- set n_parts = split_name | length -%}
+    
+    {{ split_name[1] if n_parts>1 else node_name }}
+
+{%- endmacro -%}
+
+
+{% macro generate_schema_name(custom_schema_name=none, node=none) -%}
+    {%- set default_schema = target.schema -%}
+    {%- set node_name = node.name | trim -%}
+    {%- set split_name = node_name.split('.') -%}
+    {%- set n_parts = split_name | length -%}
+    
+    {{ split_name[0] if n_parts>1 else default_schema }}
+
+{%- endmacro -%}
diff --git a/test/integration/024_custom_schema_test/dot-models/first_schema.view_1.sql b/test/integration/024_custom_schema_test/dot-models/first_schema.view_1.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/024_custom_schema_test/dot-models/first_schema.view_1.sql
@@ -0,0 +1,2 @@
+
+select * from {{ target.schema }}.seed
diff --git a/test/integration/024_custom_schema_test/dot-models/second_schema.view_2.sql b/test/integration/024_custom_schema_test/dot-models/second_schema.view_2.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/024_custom_schema_test/dot-models/second_schema.view_2.sql
@@ -0,0 +1,2 @@
+
+select * from {{ ref('first_schema.view_1') }}
diff --git a/test/integration/024_custom_schema_test/dot-models/view_3.sql b/test/integration/024_custom_schema_test/dot-models/view_3.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/024_custom_schema_test/dot-models/view_3.sql
@@ -0,0 +1,30 @@
+
+{{ config(materialized='table') }}
+
+
+with v1 as (
+
+    select * from {{ ref('first_schema.view_1') }}
+
+),
+
+v2 as (
+
+    select * from {{ ref('second_schema.view_2') }}
+
+),
+
+combined as (
+
+    select last_name from v1
+    union all
+    select last_name from v2
+
+)
+
+select
+    last_name,
+    count(*) as count
+
+from combined
+group by 1
diff --git a/test/integration/024_custom_schema_test/seed/seed.csv b/test/integration/024_custom_schema_test/seed/first_schema.my_seed.csv
similarity index 100%
rename from test/integration/024_custom_schema_test/seed/seed.csv
rename to test/integration/024_custom_schema_test/seed/first_schema.my_seed.csv
diff --git a/test/integration/024_custom_schema_test/seed/agg.csv b/test/integration/024_custom_schema_test/seed/second_schema.my_agg.csv
similarity index 100%
rename from test/integration/024_custom_schema_test/seed/agg.csv
rename to test/integration/024_custom_schema_test/seed/second_schema.my_agg.csv
diff --git a/test/integration/024_custom_schema_test/test_custom_schema.py b/test/integration/024_custom_schema_test/test_custom_schema.py
--- a/test/integration/024_custom_schema_test/test_custom_schema.py
+++ b/test/integration/024_custom_schema_test/test_custom_schema.py
@@ -289,3 +289,81 @@ def test__postgres__custom_schema_from_macro(self):
         self.assertTablesEqual("seed", "view_1", schema, v1_schema)
         self.assertTablesEqual("seed", "view_2", schema, v2_schema)
         self.assertTablesEqual("agg", "view_3", schema, xf_schema)
+
+
+class TestCustomSchemaWithCustomMacroFromModelName(DBTIntegrationTest):
+    def setUp(self):
+        super().setUp()
+
+        self._created_schemas.add(
+            self._get_schema_fqn(self.default_database, self.v1_schema())
+        )
+        self._created_schemas.add(
+            self._get_schema_fqn(self.default_database, self.v2_schema())
+        )
+
+    @property
+    def schema(self):
+        return "custom_macro_024"
+
+    @property
+    def models(self):
+        return "dot-models"
+
+    @property
+    def profile_config(self):
+        return {
+            'test': {
+                'outputs': {
+                    'prod': {
+                        'type': 'postgres',
+                        'threads': 1,
+                        'host': self.database_host,
+                        'port': 5432,
+                        'user': 'root',
+                        'pass': 'password',
+                        'dbname': 'dbt',
+                        'schema': self.unique_schema(),
+                    }
+                },
+                'target': 'prod'
+            }
+        }
+
+    @property
+    def project_config(self):
+        return {
+            'config-version': 2,
+            'macro-paths': ['custom-macros-multischema'],
+            'data-paths': ['seed'],
+            'seeds': {
+                'quote_columns': False,
+            },            
+            'models': {
+                'schema': 'dbt_test',
+            }
+        }
+
+    def v1_schema(self):
+        return f"first_schema"
+
+    def v2_schema(self):
+        return f"second_schema"
+
+    @use_profile('postgres')
+    def test__postgres__custom_schema_from_macro_model_name(self):
+        self.run_sql_file("seed.sql")
+        default_schema = self.unique_schema()
+
+        results = self.run_dbt(['seed'])
+        self.assertEqual(len(results), 2)
+        self.assertTableDoesExist("my_seed", self.v1_schema())
+        self.assertTableDoesExist("my_agg", self.v2_schema())
+
+        results = self.run_dbt()
+        self.assertEqual(len(results), 3)
+
+        self.assertTablesEqual("seed", "view_1", default_schema, self.v1_schema())
+        self.assertTablesEqual("seed", "view_2", default_schema, self.v2_schema())
+        self.assertTablesEqual("agg", "view_3", default_schema, default_schema)
+
diff --git a/test/unit/test_graph_selector_methods.py b/test/unit/test_graph_selector_methods.py
--- a/test/unit/test_graph_selector_methods.py
+++ b/test/unit/test_graph_selector_methods.py
@@ -458,11 +458,46 @@ def ext_source_id_unique(ext_source):
 def view_test_nothing(view_model):
     return make_data_test('pkg', 'view_test_nothing', 'select * from {{ ref("view_model") }} limit 0', refs=[view_model])
 
+# Support dots as namespace separators
+@pytest.fixture
+def namespaced_seed():
+    return make_seed(
+        'pkg',
+        'mynamespace.seed'
+    )
+
+@pytest.fixture
+def namespace_model(source):
+    return make_model(
+        'pkg',
+        'mynamespace.ephemeral_model',
+        'select * from {{ source("raw", "seed") }}',
+        config_kwargs={'materialized': 'ephemeral'},
+        sources=[source],
+    )
+
+@pytest.fixture
+def namespaced_union_model(seed, ext_source):
+    return make_model(
+        'pkg',
+        'mynamespace.union_model',
+        'select * from {{ ref("mynamespace.seed") }} union all select * from {{ ref("mynamespace.ephemeral_model") }}',
+        config_kwargs={'materialized': 'table'},
+        refs=[seed],
+        sources=[ext_source],
+        fqn_extras=['unions'],
+        path='subdirectory/union_model.sql',
+        tags=['unions'],
+    )
+
 
 @pytest.fixture
-def manifest(seed, source, ephemeral_model, view_model, table_model, ext_source, ext_model, union_model, ext_source_2, ext_source_other, ext_source_other_2, table_id_unique, table_id_not_null, view_id_unique, ext_source_id_unique, view_test_nothing):
+def manifest(seed, source, ephemeral_model, view_model, table_model, ext_source, ext_model, union_model, ext_source_2, 
+    ext_source_other, ext_source_other_2, table_id_unique, table_id_not_null, view_id_unique, ext_source_id_unique, 
+    view_test_nothing, namespaced_seed, namespace_model, namespaced_union_model):
     nodes = [seed, ephemeral_model, view_model, table_model, union_model, ext_model,
-             table_id_unique, table_id_not_null, view_id_unique, ext_source_id_unique, view_test_nothing]
+             table_id_unique, table_id_not_null, view_id_unique, ext_source_id_unique, view_test_nothing,
+             namespaced_seed, namespace_model, namespaced_union_model]
     sources = [source, ext_source, ext_source_2,
                ext_source_other, ext_source_other_2]
     manifest = Manifest(
@@ -492,11 +527,12 @@ def test_select_fqn(manifest):
     assert method.arguments == []
 
     assert search_manifest_using_method(
-        manifest, method, 'pkg.unions') == {'union_model'}
+        manifest, method, 'pkg.unions') == {'union_model', 'mynamespace.union_model'}
     assert not search_manifest_using_method(manifest, method, 'ext.unions')
     # sources don't show up, because selection pretends they have no FQN. Should it?
     assert search_manifest_using_method(manifest, method, 'pkg') == {
-        'union_model', 'table_model', 'view_model', 'ephemeral_model', 'seed'}
+        'union_model', 'table_model', 'view_model', 'ephemeral_model', 'seed',
+        'mynamespace.union_model', 'mynamespace.ephemeral_model', 'mynamespace.seed'}
     assert search_manifest_using_method(
         manifest, method, 'ext') == {'ext_model'}
 
@@ -573,7 +609,9 @@ def test_select_package(manifest):
     assert method.arguments == []
 
     assert search_manifest_using_method(manifest, method, 'pkg') == {'union_model', 'table_model', 'view_model', 'ephemeral_model',
-                                                                     'seed', 'raw.seed', 'unique_table_model_id', 'not_null_table_model_id', 'unique_view_model_id', 'view_test_nothing'}
+                                                                     'seed', 'raw.seed', 'unique_table_model_id', 'not_null_table_model_id', 'unique_view_model_id', 'view_test_nothing',
+                                                                     'mynamespace.seed', 'mynamespace.ephemeral_model', 'mynamespace.union_model',
+                                                                     }
     assert search_manifest_using_method(manifest, method, 'ext') == {
         'ext_model', 'ext_raw.ext_source', 'ext_raw.ext_source_2', 'raw.ext_source', 'raw.ext_source_2', 'unique_ext_raw_ext_source_id'}
 
@@ -589,7 +627,7 @@ def test_select_config_materialized(manifest):
     assert search_manifest_using_method(manifest, method, 'view') == {
         'view_model', 'ext_model'}
     assert search_manifest_using_method(manifest, method, 'table') == {
-        'table_model', 'union_model'}
+        'table_model', 'union_model', 'mynamespace.union_model'}
 
 
 def test_select_test_name(manifest):