Repository: datafold/data-diff
Issue: Handle type changes

**Describe the bug**
I have a column named "city" which has varchar city names in prod

If I update my local model to use integers and run a diff, I receive this error:
```
Running with data-diff=0.7.4

[16:19:22] ERROR - 100038 (22018): Numeric value 'Minneapolis' is not recognized
```

I'd prefer the diff to skip this column and show:
```
Type change: ['CITY']
```
Instead of erroring out.

Changed files:
tests/test_dbt.py, data_diff/utils.py, data_diff/dbt.py

diff --git a/data_diff/dbt.py b/data_diff/dbt.py
index b9693b89..4255fa9b 100644
--- a/data_diff/dbt.py
+++ b/data_diff/dbt.py
@@ -12,8 +12,8 @@ from .utils import (
     columns_added_template,
     columns_removed_template,
     no_differences_template,
+    columns_type_changed_template,
 )
-from pathlib import Path
 
 import keyring
 
@@ -191,9 +191,9 @@ def _local_diff(diff_vars: TDiffVars) -> None:
         diff_vars.connection, prod_qualified_str, tuple(diff_vars.primary_keys), diff_vars.threads
     )
 
-    table1_columns = list(table1.get_schema())
+    table1_columns = table1.get_schema()
     try:
-        table2_columns = list(table2.get_schema())
+        table2_columns = table2.get_schema()
     # Not ideal, but we don't have more specific exceptions yet
     except Exception as ex:
         logger.debug(ex)
@@ -201,9 +201,15 @@ def _local_diff(diff_vars: TDiffVars) -> None:
         rich.print(diff_output_str)
         return
 
-    column_set = set(table1_columns).intersection(table2_columns)
-    columns_added = set(table1_columns).difference(table2_columns)
-    columns_removed = set(table2_columns).difference(table1_columns)
+    table1_column_names = set(table1_columns.keys())
+    table2_column_names = set(table2_columns.keys())
+    column_set = table1_column_names.intersection(table2_column_names)
+    columns_added = table1_column_names.difference(table2_column_names)
+    columns_removed = table2_column_names.difference(table1_column_names)
+    # col type is i = 1 in tuple
+    columns_type_changed = {
+        k for k, v in table1_columns.items() if k in table2_columns and v[1] != table2_columns[k][1]
+    }
 
     if columns_added:
         diff_output_str += columns_added_template(columns_added)
@@ -211,6 +217,10 @@ def _local_diff(diff_vars: TDiffVars) -> None:
     if columns_removed:
         diff_output_str += columns_removed_template(columns_removed)
 
+    if columns_type_changed:
+        diff_output_str += columns_type_changed_template(columns_type_changed)
+        column_set = column_set.difference(columns_type_changed)
+
     column_set = column_set - set(diff_vars.primary_keys)
 
     if diff_vars.include_columns:
@@ -321,7 +331,7 @@ def _cloud_diff(diff_vars: TDiffVars, datasource_id: int, api: DatafoldAPI, org_
             diff_output_str += columns_removed_template(columns_removed)
 
         if column_type_changes:
-            diff_output_str += "Type change: " + str(column_type_changes) + "\\n"
+            diff_output_str += columns_type_changed_template(column_type_changes)
 
         if any([rows_added_count, rows_removed_count, rows_updated]):
             diff_output = dbt_diff_string_template(
diff --git a/data_diff/utils.py b/data_diff/utils.py
index 01daa305..6e84caa8 100644
--- a/data_diff/utils.py
+++ b/data_diff/utils.py
@@ -171,14 +171,19 @@ def diffs_are_equiv_jsons(diff: list, json_cols: dict):
     return match, overriden_diff_cols
 
 
-def columns_removed_template(table2_set_diff) -> str:
-    columns_removed = "Column(s) removed: " + str(table2_set_diff) + "\\n"
-    return columns_removed
+def columns_removed_template(columns_removed) -> str:
+    columns_removed_str = f"Column(s) removed: {columns_removed}\\n"
+    return columns_removed_str
 
 
-def columns_added_template(table1_set_diff) -> str:
-    columns_added = "Column(s) added: " + str(table1_set_diff) + "\\n"
-    return columns_added
+def columns_added_template(columns_added) -> str:
+    columns_added_str = f"Column(s) added: {columns_added}\\n"
+    return columns_added_str
+
+
+def columns_type_changed_template(columns_type_changed) -> str:
+    columns_type_changed_str = f"Type change: {columns_type_changed}\\n"
+    return columns_type_changed_str
 
 
 def no_differences_template() -> str:
diff --git a/tests/test_dbt.py b/tests/test_dbt.py
index 1bb93a15..78eaa506 100644
--- a/tests/test_dbt.py
+++ b/tests/test_dbt.py
@@ -442,21 +442,6 @@ class TestDbtParser(unittest.TestCase):
             _, _ = DbtParser.get_connection_creds(mock_self)
 
 
-EXAMPLE_DIFF_RESULTS = {
-    "pks": {"exclusives": [5, 3]},
-    "values": {
-        "rows_with_differences": 2,
-        "total_rows": 10,
-        "columns_diff_stats": [
-            {"column_name": "name", "match": 80.0},
-            {"column_name": "age", "match": 100.0},
-            {"column_name": "city", "match": 0.0},
-            {"column_name": "country", "match": 100.0},
-        ],
-    },
-}
-
-
 class TestDbtDiffer(unittest.TestCase):
     # Set DATA_DIFF_DBT_PROJ to use your own dbt project, otherwise uses the duckdb project in tests/dbt_artifacts
     def test_integration_basic_dbt(self):
@@ -488,10 +473,10 @@ class TestDbtDiffer(unittest.TestCase):
     def test_local_diff(self, mock_diff_tables):
         connection = {}
         mock_table1 = Mock()
-        column_set = {"col1", "col2"}
-        mock_table1.get_schema.return_value = column_set
+        column_dictionary = {"col1": ("col1", "type"), "col2": ("col2", "type")}
+        mock_table1.get_schema.return_value = column_dictionary
         mock_table2 = Mock()
-        mock_table2.get_schema.return_value = column_set
+        mock_table2.get_schema.return_value = column_dictionary
         mock_diff = MagicMock()
         mock_diff_tables.return_value = mock_diff
         mock_diff.__iter__.return_value = [1, 2, 3]
@@ -527,14 +512,56 @@ class TestDbtDiffer(unittest.TestCase):
         mock_connect.assert_any_call(connection, ".".join(prod_qualified_list), tuple(expected_primary_keys), threads)
         mock_diff.get_stats_string.assert_called_once()
 
+    @patch("data_diff.dbt.diff_tables")
+    def test_local_diff_types_differ(self, mock_diff_tables):
+        connection = {}
+        mock_table1 = Mock()
+        mock_table2 = Mock()
+        table1_column_dictionary = {"col1": ("col1", "type"), "col2": ("col2", "type")}
+        table2_column_dictionary = {"col1": ("col1", "type"), "col2": ("col2", "differing_type")}
+        mock_table1.get_schema.return_value = table1_column_dictionary
+        mock_table2.get_schema.return_value = table2_column_dictionary
+        mock_diff = MagicMock()
+        mock_diff_tables.return_value = mock_diff
+        mock_diff.__iter__.return_value = [1, 2, 3]
+        threads = None
+        where = "a_string"
+        dev_qualified_list = ["dev_db", "dev_schema", "dev_table"]
+        prod_qualified_list = ["prod_db", "prod_schema", "prod_table"]
+        expected_primary_keys = ["key"]
+        diff_vars = TDiffVars(
+            dev_path=dev_qualified_list,
+            prod_path=prod_qualified_list,
+            primary_keys=expected_primary_keys,
+            connection=connection,
+            threads=threads,
+            where_filter=where,
+            include_columns=[],
+            exclude_columns=[],
+        )
+        with patch("data_diff.dbt.connect_to_table", side_effect=[mock_table1, mock_table2]) as mock_connect:
+            _local_diff(diff_vars)
+
+        mock_diff_tables.assert_called_once_with(
+            mock_table1,
+            mock_table2,
+            threaded=True,
+            algorithm=Algorithm.JOINDIFF,
+            extra_columns=ANY,
+            where=where,
+        )
+        self.assertEqual(len(mock_diff_tables.call_args[1]["extra_columns"]), 1)
+        self.assertEqual(mock_connect.call_count, 2)
+        mock_diff.get_stats_string.assert_called_once()
+
     @patch("data_diff.dbt.diff_tables")
     def test_local_diff_no_diffs(self, mock_diff_tables):
         connection = {}
-        column_set = {"col1", "col2"}
+        column_dictionary = {"col1": ("col1", "type"), "col2": ("col2", "type")}
         mock_table1 = Mock()
-        mock_table1.get_schema.return_value = column_set
+        mock_table1.get_schema.return_value = column_dictionary
         mock_table2 = Mock()
-        mock_table2.get_schema.return_value = column_set
+        mock_table2.get_schema.return_value = column_dictionary
         mock_diff = MagicMock()
         mock_diff_tables.return_value = mock_diff
         mock_diff.__iter__.return_value = []