Repository: dbt-labs/dbt-core

Issue:
[CT-520] [Bug] Schema files with repeated top level keys (e.g. `models`) results in only the last key being parsed
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

If you have a schema file with repeated top level keys:

```yml
# models/schema.yml
version: 2
models:
  - name: my_model_a
    description: "This is a"
    columns:
      - name: user_name
        tests:
          - not_null
models:
  - name: my_model_b
    description: "This is b"
    columns:
      - name: user_name
        tests:
          - not_null
```

Only the last `models` key will apply to the project when you execute dbt tasks that depend on them (like `test`, `docs generate`). We should raise an exception for such cases.

### Expected Behavior

Perhaps we should raise an error in this case instead of silently just including the last `models` key of the schema file in our project's manifest.

### Steps To Reproduce

1. Add the above `schema.yml` file to your project along with 2 simple models:

```sql
-- models/my_model_a.sql
select 1 as user_name
-- models/my_model_b.sql
select 1 as user_name
```

2. Do a `dbt run` to build our models.
3. Do a `dbt test` and observe that we only tested `my_model_b`.
4. Do a `dbt docs generate && dbt docs serve` and observe that only `my_model_b` has a description (`my_model_a` has no description).


### Relevant log output

```shell
$ dbt test
04:02:08  Running with dbt=1.0.4
04:02:09  Found 2 models, 1 test, 0 snapshots, 0 analyses, 165 macros, 0 operations, 0 seed files, 0 sources, 0 exposures, 0 metrics
04:02:09  
04:02:09  Concurrency: 1 threads (target='dev')
04:02:09  
04:02:09  1 of 1 START test not_null_my_model_b_user_name................................. [RUN]
04:02:09  1 of 1 PASS not_null_my_model_b_user_name....................................... [PASS in 0.07s]
04:02:09  
04:02:09  Finished running 1 test in 0.24s.
04:02:09  
04:02:09  Completed successfully
04:02:09  
04:02:09  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1
```


### Environment

```markdown
- OS: macOS
- Python: 3.9.7
- dbt: 1.0.4
```


### What database are you using dbt with?

postgres

### Additional Context

![image](https://user-images.githubusercontent.com/8496748/164148216-f574fa01-d5b9-4386-a49c-64e40057d677.png)



Discussion:
Here's the [relevant issue](https://github.com/yaml/pyyaml/issues/165) and a [fix](https://gist.github.com/pypt/94d747fe5180851196eb?permalink_comment_id=4015118#gistcomment-4015118) we could try which extends `SafeLoader`. 

Add the extended class to our [`yaml_helper` client](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/clients/yaml_helper.py) then add in a `ValueError` in the following tuple:

https://github.com/dbt-labs/dbt-core/blob/37b8b65aadb811c6f56c81537da48a463857b0d9/core/dbt/clients/yaml_helper.py#L57

Like so:

```python
    except (yaml.scanner.ScannerError, yaml.YAMLError, ValueError) as e:
```

----

Then our `dbt test` / `dbt docs generate`) should result in:

```
$ dbt test
04:10:34  Running with dbt=1.0.4
04:10:35  Encountered an error:
Parsing Error
  Error reading postgres: schema.yml - Runtime Error
    Duplicate 'models' key found in YAML.
```



(Maybe a warning instead of an error, for starters? Then we could switch it to error-level, in a future minor version)
@jeremyyeo thanks for this amazing write up and suggested solution!  

I agree with @jtcohen6 that a warning would be a good place to start here.  It would need to be caught separate from the`yaml.scanner.ScannerError` and `yaml.YAMLError` since we want to handle it differently.  [`warn_or_error`](https://github.com/dbt-labs/dbt-core/blob/7d0fccd63f0a50e6f8b1130206118d1fcc19b2ae/core/dbt/exceptions.py#L1027) could be leveraged on a custom exception we raise in the fix you mention above as opposed to just a `ValueError`.

```
class UniqueKeyLoader(yaml.SafeLoader):
    def construct_mapping(self, node, deep=False):
        mapping = set()
        for key_node, value_node in node.value:
            key = self.construct_object(key_node, deep=deep)
            if key in mapping:
                raise DuplicateYamlKeyException(f"Duplicate {key!r} key found in YAML.")
            mapping.add(key)
        return super().construct_mapping(node, deep)
```

Then define that new exception in [`exceptions.py`](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/exceptions.py).

Additional it would be useful to add a test that checks we trigger a warning as expected.  If/when we convert to an error, the test would need to be updated.  All of our duplication tests live in [025_duplicate_model_tests](https://github.com/dbt-labs/dbt-core/tree/main/test/integration/025_duplicate_model_tests) but we are currently in the process of converting all of our tests to a more intuitive and consistent framework.  The new test would live in [`/dbt/tests/functional`](https://github.com/dbt-labs/dbt-core/tree/main/tests/functional) in a new folder, possibly named `duplication`.   It would need to be written in our new testing framework.  

If you or another community member want to work on this I would be happy to help give some direction on writing the new test and any other questions there may be.


Thanks for the guidance @emmyoop + @jtcohen6. Will take a crack at the raising a new warning instead. Possibly will need help adding the test later but will circle back on that.

Resolving patch:
diff --git a/core/dbt/clients/yaml_helper.py b/core/dbt/clients/yaml_helper.py
--- a/core/dbt/clients/yaml_helper.py
+++ b/core/dbt/clients/yaml_helper.py
@@ -8,6 +8,7 @@
 except ImportError:
     from yaml import Loader, SafeLoader, Dumper  # type: ignore  # noqa: F401
 
+from dbt.ui import warning_tag
 
 YAML_ERROR_MESSAGE = """
 Syntax error near line {line_number}
@@ -20,6 +21,26 @@
 """.strip()
 
 
+class UniqueKeyLoader(SafeLoader):
+    """A subclass that checks for unique yaml mapping nodes.
+
+    This class extends `SafeLoader` from the `yaml` library to check for
+    unique top level keys (mapping nodes). See issue (https://github.com/yaml/pyyaml/issues/165)
+    and solution (https://gist.github.com/pypt/94d747fe5180851196eb?permalink_comment_id=4015118).
+    """
+
+    def construct_mapping(self, node, deep=False):
+        mapping = set()
+        for key_node, value_node in node.value:
+            key = self.construct_object(key_node, deep=deep)
+            if key in mapping:
+                raise dbt.exceptions.DuplicateYamlKeyException(
+                    f"Duplicate {key!r} key found in yaml file"
+                )
+            mapping.add(key)
+        return super().construct_mapping(node, deep)
+
+
 def line_no(i, line, width=3):
     line_number = str(i).ljust(width)
     return "{}| {}".format(line_number, line)
@@ -48,10 +69,10 @@ def contextualized_yaml_error(raw_contents, error):
 
 
 def safe_load(contents) -> Optional[Dict[str, Any]]:
-    return yaml.load(contents, Loader=SafeLoader)
+    return yaml.load(contents, Loader=UniqueKeyLoader)
 
 
-def load_yaml_text(contents):
+def load_yaml_text(contents, path=None):
     try:
         return safe_load(contents)
     except (yaml.scanner.ScannerError, yaml.YAMLError) as e:
@@ -61,3 +82,7 @@ def load_yaml_text(contents):
             error = str(e)
 
         raise dbt.exceptions.ValidationException(error)
+    except dbt.exceptions.DuplicateYamlKeyException as e:
+        # TODO: We may want to raise an exception instead of a warning in the future.
+        e.msg = f"{e} {path.searched_path}/{path.relative_path}."
+        dbt.exceptions.warn_or_raise(e, log_fmt=warning_tag("{}"))
diff --git a/core/dbt/exceptions.py b/core/dbt/exceptions.py
--- a/core/dbt/exceptions.py
+++ b/core/dbt/exceptions.py
@@ -436,6 +436,10 @@ def __init__(self, name: str):
         super().__init__(name)
 
 
+class DuplicateYamlKeyException(CompilationException):
+    pass
+
+
 def raise_compiler_error(msg, node=None) -> NoReturn:
     raise CompilationException(msg, node)
 
@@ -903,7 +907,8 @@ def raise_ambiguous_alias(node_1, node_2, duped_name=None):
 def raise_ambiguous_catalog_match(unique_id, match_1, match_2):
     def get_match_string(match):
         return "{}.{}".format(
-            match.get("metadata", {}).get("schema"), match.get("metadata", {}).get("name")
+            match.get("metadata", {}).get("schema"),
+            match.get("metadata", {}).get("name"),
         )
 
     raise_compiler_error(
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
@@ -115,7 +115,7 @@ def yaml_from_file(source_file: SchemaSourceFile) -> Dict[str, Any]:
     """If loading the yaml fails, raise an exception."""
     path = source_file.path.relative_path
     try:
-        return load_yaml_text(source_file.contents)
+        return load_yaml_text(source_file.contents, source_file.path)
     except ValidationException as e:
         reason = validator_error_message(e)
         raise ParsingException(
@@ -548,7 +548,8 @@ def parse_file(self, block: FileBlock, dct: Dict = None) -> None:
 def check_format_version(file_path, yaml_dct) -> None:
     if "version" not in yaml_dct:
         raise_invalid_property_yml_version(
-            file_path, "the yml property file {} is missing a version tag".format(file_path)
+            file_path,
+            "the yml property file {} is missing a version tag".format(file_path),
         )
 
     version = yaml_dct["version"]
@@ -562,7 +563,8 @@ def check_format_version(file_path, yaml_dct) -> None:
         )
     if version != 2:
         raise_invalid_property_yml_version(
-            file_path, "its 'version:' tag is set to {}.  Only 2 is supported".format(version)
+            file_path,
+            "its 'version:' tag is set to {}.  Only 2 is supported".format(version),
         )
 
 


Test patch:
diff --git a/test/integration/006_simple_dependency_tests/local_dependency/models/schema.yml b/test/integration/006_simple_dependency_tests/local_dependency/models/schema.yml
--- a/test/integration/006_simple_dependency_tests/local_dependency/models/schema.yml
+++ b/test/integration/006_simple_dependency_tests/local_dependency/models/schema.yml
@@ -4,7 +4,6 @@ sources:
     schema: invalid_schema
     tables:
       - name: my_table
-sources:
   - name: seed_source
     schema: "{{ var('schema_override', target.schema) }}"
     tables:
diff --git a/tests/functional/duplications/test_basic_duplications.py b/tests/functional/duplications/test_basic_duplications.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/duplications/test_basic_duplications.py
@@ -0,0 +1,33 @@
+import pytest
+
+from dbt.tests.util import run_dbt_and_capture, run_dbt
+from dbt.exceptions import DuplicateYamlKeyException
+
+duplicate_key_schema__schema_yml = """
+version: 2
+models:
+  - name: my_model
+models:
+  - name: my_model
+"""
+
+my_model_sql = """
+  select 1 as fun
+"""
+
+
+class TestBasicDuplications:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "my_model.sql": my_model_sql,
+            "schema.yml": duplicate_key_schema__schema_yml,
+        }
+
+    def test_warning_in_stdout(self, project):
+        results, stdout = run_dbt_and_capture(["run"])
+        assert "Duplicate 'models' key found in yaml file models/schema.yml" in stdout
+
+    def test_exception_is_raised_with_warn_error_flag(self, project):
+        with pytest.raises(DuplicateYamlKeyException):
+            run_dbt(["--warn-error", "run"])