Repository: dbt-labs/dbt-core

Issue:
[CT-2322] [Feature] Side-by-side diff when an enforced model contract fails
### Is this your first time submitting a feature request?

- [X] I have read the [expectations for open source contributors](https://docs.getdbt.com/docs/contributing/oss-expectations)
- [X] I have searched the existing issues, and I could not find an existing issue for this feature
- [X] I am requesting a straightforward extension of existing dbt functionality, rather than a Big Idea better suited to a discussion

### Describe the feature

See this Loom starting at 12:50 for context of this feature proposal:
https://www.loom.com/share/f436e5e336e4433695dc63d53ee72fcf?t=770

Two things:
- Update "Contracts are enabled for this model." to "This model has an enforced contract."
- Do a side-by-side diff rather than a stacked listing of column names

Where a preflight check fails for a model contract, could the output look more like this?

<img width="650" alt="image" src="https://user-images.githubusercontent.com/44704949/226979830-b6ce1f81-01e6-42cb-99f9-3a91017fa3c8.png">

Right now, the output looks like this:

<img width="650" alt="image" src="https://user-images.githubusercontent.com/44704949/226978591-41c952a0-d682-434a-a3c3-ff329c97b2cd.png"> snip...
<img width="650" alt="image" src="https://user-images.githubusercontent.com/44704949/226978227-b5e5acef-9eb2-40dd-b871-41a413d97191.png">



### Describe alternatives you've considered

Sometimes the status quo is the way to go ...
... and sometimes it isn't 😉 

### Who will this benefit?

This will benefit folks that:
- are using enforced model contracts
- there is a mismatch in the contract leading to a preflight failure
- have a model with tons of columns (or even just a few!)

### Are you interested in contributing this feature?

_No response_

### Anything else?

_No response_


Discussion:
This change wants to happen here:

https://github.com/dbt-labs/dbt-core/blob/a203fe866ad3e969e7de9cc24ddbbef1934aa7d0/core/dbt/exceptions.py#L2127-L2141

This would also require updating a few tests, which currently expect to see some specific text in the logs. It's definitely worth doing for the UX improvement :)

Resolving patch:
diff --git a/core/dbt/exceptions.py b/core/dbt/exceptions.py
--- a/core/dbt/exceptions.py
+++ b/core/dbt/exceptions.py
@@ -1,6 +1,8 @@
 import builtins
 import json
 import re
+import io
+import agate
 from typing import Any, Dict, List, Mapping, Optional, Union
 
 from dbt.dataclass_schema import ValidationError
@@ -2145,14 +2147,65 @@ def __init__(self, yaml_columns, sql_columns):
         self.sql_columns = sql_columns
         super().__init__(msg=self.get_message())
 
-    def get_message(self) -> str:
+    def get_mismatches(self) -> agate.Table:
+        # avoid a circular import
+        from dbt.clients.agate_helper import table_from_data_flat
+
+        column_names = ["column_name", "definition_type", "contract_type", "mismatch_reason"]
+        # list of mismatches
+        mismatches: List[Dict[str, str]] = []
+        # track sql cols so we don't need another for loop later
+        sql_col_set = set()
+        # for each sql col list
+        for sql_col in self.sql_columns:
+            # add sql col to set
+            sql_col_set.add(sql_col["name"])
+            # for each yaml col list
+            for i, yaml_col in enumerate(self.yaml_columns):
+                # if name matches
+                if sql_col["name"] == yaml_col["name"]:
+                    # if type matches
+                    if sql_col["data_type"] == yaml_col["data_type"]:
+                        # its a perfect match! don't include in mismatch table
+                        break
+                    else:
+                        # same name, diff type
+                        row = [
+                            sql_col["name"],
+                            sql_col["data_type"],
+                            yaml_col["data_type"],
+                            "data type mismatch",
+                        ]
+                        mismatches += [dict(zip(column_names, row))]
+                        break
+                # if last loop, then no name match
+                if i == len(self.yaml_columns) - 1:
+                    row = [sql_col["name"], sql_col["data_type"], "", "missing in contract"]
+                    mismatches += [dict(zip(column_names, row))]
+
+        # now add all yaml cols without a match
+        for yaml_col in self.yaml_columns:
+            if yaml_col["name"] not in sql_col_set:
+                row = [yaml_col["name"], "", yaml_col["data_type"], "missing in definition"]
+                mismatches += [dict(zip(column_names, row))]
+
+        mismatches_sorted = sorted(mismatches, key=lambda d: d["column_name"])
+        return table_from_data_flat(mismatches_sorted, column_names)
+
+    def get_message(self) -> str:
+        table: agate.Table = self.get_mismatches()
+        # Hack to get Agate table output as string
+        output = io.StringIO()
+        table.print_table(output=output, max_rows=None, max_column_width=50)  # type: ignore
+        mismatches = output.getvalue()
+
         msg = (
-            "Contracts are enabled for this model. "
-            "Please ensure the name, data_type, and number of columns in your `yml` file "
-            "match the columns in your SQL file.\n"
-            f"Schema File Columns: {self.yaml_columns}\n"
-            f"SQL File Columns: {self.sql_columns}"
+            "This model has an enforced contract that failed.\n"
+            "Please ensure the name, data_type, and number of columns in your contract "
+            "match the columns in your model's definition.\n\n"
+            f"{mismatches}"
         )
+
         return msg
 
 


Test patch:
diff --git a/tests/adapter/dbt/tests/adapter/constraints/test_constraints.py b/tests/adapter/dbt/tests/adapter/constraints/test_constraints.py
--- a/tests/adapter/dbt/tests/adapter/constraints/test_constraints.py
+++ b/tests/adapter/dbt/tests/adapter/constraints/test_constraints.py
@@ -80,17 +80,8 @@ def test__constraints_wrong_column_names(self, project, string_type, int_type):
 
         assert contract_actual_config.enforced is True
 
-        expected_compile_error = "Please ensure the name, data_type, and number of columns in your `yml` file match the columns in your SQL file."
-        expected_schema_file_columns = (
-            f"Schema File Columns: id {int_type}, color {string_type}, date_day {string_type}"
-        )
-        expected_sql_file_columns = (
-            f"SQL File Columns: color {string_type}, error {int_type}, date_day {string_type}"
-        )
-
-        assert expected_compile_error in log_output
-        assert expected_schema_file_columns in log_output
-        assert expected_sql_file_columns in log_output
+        expected = ["id", "error", "missing in definition", "missing in contract"]
+        assert all([(exp in log_output or exp.upper() in log_output) for exp in expected])
 
     def test__constraints_wrong_column_data_types(
         self, project, string_type, int_type, schema_int_type, data_types
@@ -128,18 +119,13 @@ def test__constraints_wrong_column_data_types(
             contract_actual_config = my_model_config.contract
 
             assert contract_actual_config.enforced is True
-
-            expected_compile_error = "Please ensure the name, data_type, and number of columns in your `yml` file match the columns in your SQL file."
-            expected_sql_file_columns = (
-                f"SQL File Columns: wrong_data_type_column_name {error_data_type}"
-            )
-            expected_schema_file_columns = (
-                f"Schema File Columns: wrong_data_type_column_name {wrong_schema_error_data_type}"
-            )
-
-            assert expected_compile_error in log_output
-            assert expected_schema_file_columns in log_output
-            assert expected_sql_file_columns in log_output
+            expected = [
+                "wrong_data_type_column_name",
+                error_data_type,
+                wrong_schema_error_data_type,
+                "data type mismatch",
+            ]
+            assert all([(exp in log_output or exp.upper() in log_output) for exp in expected])
 
     def test__constraints_correct_column_data_types(self, project, data_types):
         for (sql_column_value, schema_data_type, _) in data_types: