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
Also fix for the docs site: https://github.com/fishtown-analytics/dbt-docs/pull/185
@monti-python Wow! You've really done your homework. Thanks for opening all these issues and associated PRs, and sorry for the delay in review on my end.

Honestly, I'm pleasantly surprised that models-with-dots-in-them work as well as they do today. It's not a pattern I see often, even though (as you mention) it's something that comes up occasionally on discourse and in broader namespace conversations such as #1269.

I guess the operative questions are:
- Is this a pattern that dbt should consistently and reliably support? I think yes. To that end, I'll review the PRs you've opened, and may leave comments where certain functionality is now supported for models but not other resource types. I also think we should add tests, to ensure this functionality remains supported in future dbt versions.
- Is this a pattern we should document, with trade-offs and recommendations? There I'm not so sure. When would dots be preferable to a convention around, say, double underscores?

As I understand it, in order for this to work on Snowflake, you'd need to either:
- Alias `model.with.dots` to `model_without_dots`
- Quote the identifier name, which opens up a can of quoting/casing worms on Snowflake on particular
@jtcohen6 thank you for your feedback! I've updated the PR to include some integration tests as well. IMO this feature only makes sense when combined with custom parsing of `schema` and `alias` (and potentially `db`) from the model name, as the dot character is the natural separator for these entities. 

At the beginning we were using double underscores as separators, but we came across some relations containing double underscores in their name. Worse yet, there might also be schemas containing double underscores, making it unusable as a separator... Of course there is always the option of a more complex separator (triple underscores for instance), but the dot character felt like the more natural way to tackle the namespacing problem, and it worked! 

Resolving patch:
diff --git a/core/dbt/parser/read_files.py b/core/dbt/parser/read_files.py
--- a/core/dbt/parser/read_files.py
+++ b/core/dbt/parser/read_files.py
@@ -3,7 +3,8 @@
     FilePath, ParseFileType, SourceFile, FileHash, AnySourceFile, SchemaSourceFile
 )
 
-from dbt.parser.schemas import yaml_from_file
+from dbt.parser.schemas import yaml_from_file, schema_file_keys, check_format_version
+from dbt.exceptions import CompilationException
 from dbt.parser.search import FilesystemSearcher
 
 
@@ -17,11 +18,36 @@ def load_source_file(
     source_file = sf_cls(path=path, checksum=checksum,
                          parse_file_type=parse_file_type, project_name=project_name)
     source_file.contents = file_contents.strip()
-    if parse_file_type == ParseFileType.Schema:
-        source_file.dfy = yaml_from_file(source_file)
+    if parse_file_type == ParseFileType.Schema and source_file.contents:
+        dfy = yaml_from_file(source_file)
+        validate_yaml(source_file.path.original_file_path, dfy)
+        source_file.dfy = dfy
     return source_file
 
 
+# Do some minimal validation of the yaml in a schema file.
+# Check version, that key values are lists and that each element in
+# the lists has a 'name' key
+def validate_yaml(file_path, dct):
+    check_format_version(file_path, dct)
+    for key in schema_file_keys:
+        if key in dct:
+            if not isinstance(dct[key], list):
+                msg = (f"The schema file at {file_path} is "
+                       f"invalid because the value of '{key}' is not a list")
+                raise CompilationException(msg)
+            for element in dct[key]:
+                if not isinstance(element, dict):
+                    msg = (f"The schema file at {file_path} is "
+                           f"invalid because a list element for '{key}' is not a dictionary")
+                    raise CompilationException(msg)
+                if 'name' not in element:
+                    msg = (f"The schema file at {file_path} is "
+                           f"invalid because a list element for '{key}' does not have a "
+                           "name attribute.")
+                    raise CompilationException(msg)
+
+
 # Special processing for big seed files
 def load_seed_source_file(match: FilePath, project_name) -> SourceFile:
     if match.seed_too_large():
diff --git a/core/dbt/parser/schemas.py b/core/dbt/parser/schemas.py
--- a/core/dbt/parser/schemas.py
+++ b/core/dbt/parser/schemas.py
@@ -70,6 +70,11 @@
 
 TestDef = Union[str, Dict[str, Any]]
 
+schema_file_keys = (
+    'models', 'seeds', 'snapshots', 'sources',
+    'macros', 'analyses', 'exposures',
+)
+
 
 def error_context(
     path: str,
@@ -200,25 +205,6 @@ def parse_from_dict(self, dct, validate=True) -> ParsedSchemaTestNode:
             ParsedSchemaTestNode.validate(dct)
         return ParsedSchemaTestNode.from_dict(dct)
 
-    def _check_format_version(
-        self, yaml: YamlBlock
-    ) -> None:
-        path = yaml.path.relative_path
-        if 'version' not in yaml.data:
-            raise_invalid_schema_yml_version(path, 'no version is specified')
-
-        version = yaml.data['version']
-        # if it's not an integer, the version is malformed, or not
-        # set. Either way, only 'version: 2' is supported.
-        if not isinstance(version, int):
-            raise_invalid_schema_yml_version(
-                path, 'the version is not an integer'
-            )
-        if version != 2:
-            raise_invalid_schema_yml_version(
-                path, 'version {} is not supported'.format(version)
-            )
-
     def parse_column_tests(
         self, block: TestBlock, column: UnparsedColumn
     ) -> None:
@@ -514,9 +500,6 @@ def parse_file(self, block: FileBlock, dct: Dict = None) -> None:
             # contains the FileBlock and the data (dictionary)
             yaml_block = YamlBlock.from_file_block(block, dct)
 
-            # checks version
-            self._check_format_version(yaml_block)
-
             parser: YamlDocsReader
 
             # There are 7 kinds of parsers:
@@ -565,6 +548,25 @@ def parse_file(self, block: FileBlock, dct: Dict = None) -> None:
                     self.manifest.add_exposure(yaml_block.file, node)
 
 
+def check_format_version(
+    file_path, yaml_dct
+) -> None:
+    if 'version' not in yaml_dct:
+        raise_invalid_schema_yml_version(file_path, 'no version is specified')
+
+    version = yaml_dct['version']
+    # if it's not an integer, the version is malformed, or not
+    # set. Either way, only 'version: 2' is supported.
+    if not isinstance(version, int):
+        raise_invalid_schema_yml_version(
+            file_path, 'the version is not an integer'
+        )
+    if version != 2:
+        raise_invalid_schema_yml_version(
+            file_path, 'version {} is not supported'.format(version)
+        )
+
+
 Parsed = TypeVar(
     'Parsed',
     UnpatchedSourceDefinition, ParsedNodePatch, ParsedMacroPatch


Test patch:
diff --git a/test/integration/008_schema_tests_test/invalid-schema-models/model.sql b/test/integration/008_schema_tests_test/invalid-schema-models/model.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/008_schema_tests_test/invalid-schema-models/model.sql
@@ -0,0 +1 @@
+select 1 as "Id"
diff --git a/test/integration/008_schema_tests_test/invalid-schema-models/schema.yml b/test/integration/008_schema_tests_test/invalid-schema-models/schema.yml
new file mode 100644
--- /dev/null
+++ b/test/integration/008_schema_tests_test/invalid-schema-models/schema.yml
@@ -0,0 +1,10 @@
+version: 2
+
+models:
+  name: model
+  columns:
+    - name: Id
+      quote: true
+      tests:
+        - unique
+        - not_null
diff --git a/test/integration/008_schema_tests_test/test_schema_v2_tests.py b/test/integration/008_schema_tests_test/test_schema_v2_tests.py
--- a/test/integration/008_schema_tests_test/test_schema_v2_tests.py
+++ b/test/integration/008_schema_tests_test/test_schema_v2_tests.py
@@ -523,3 +523,20 @@ def test_postgres_collision_test_names_get_hash(self):
         ]
         self.assertIn(test_results[0].node.unique_id, expected_unique_ids)
         self.assertIn(test_results[1].node.unique_id, expected_unique_ids)
+
+
+class TestInvalidSchema(DBTIntegrationTest):
+    @property
+    def schema(self):
+        return "schema_tests_008"
+
+    @property
+    def models(self):
+        return "invalid-schema-models"
+
+    @use_profile('postgres')
+    def test_postgres_invalid_schema_file(self):
+        with self.assertRaises(CompilationException) as exc:
+            results = self.run_dbt()
+        self.assertRegex(str(exc.exception), r"'models' is not a list")
+