Repository: TobikoData/sqlmesh (python)

Issue:
bug: table_diff fails with a pydantic error
```
demo.incremental_model ...                                                                  

Table diff failed!
Error: 10 validation errors for RowDiff
stats.s_count
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
stats.t_count
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
stats.join_count
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
stats.null_grain_count
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
stats.full_match_count
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
stats.id_matches
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
stats.item_id_matches
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
stats.event_date_matches
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
stats.s_only_count
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
stats.t_only_count
  Input should be a valid number [type=float_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.11/v/float_type
(.venv) ➜  sqlmesh-demos git:(sung/vscode) ✗                                    
```

Model that's diffed.
```
MODEL (
  name demo.incremental_model_new,
  kind INCREMENTAL_BY_TIME_RANGE (
    time_column event_date
  ),
  start '2020-01-01',
  cron '@daily',
  grain (id, event_date),
  stamp 'fix hydration issue',
  owner 'sung'
);

SELECT
  id,
  item_id,
  event_date,
  '1' AS new_column
FROM demo.seed_model
WHERE
  event_date BETWEEN @start_date AND @end_date
```

Pull request description:
Fix(table_diff): Allow diffing of empty tables
Fixes #4320 

Prior to this PR, diffing a pair of empty tables that had schema changes resulted in a Pydantic error at the row diff step:
![Screenshot From 2025-05-09 13-08-51](https://github.com/user-attachments/assets/a1b800e8-bf94-4ce3-9901-96e80c7c0815)

This PR accounts for this:
![Screenshot From 2025-05-09 13-09-06](https://github.com/user-attachments/assets/29a3a561-042e-4b13-92c3-f996d56dbb37)



Patch:
diff --git a/sqlmesh/core/console.py b/sqlmesh/core/console.py
index ad28d4dd..867497e9 100644
--- a/sqlmesh/core/console.py
+++ b/sqlmesh/core/console.py
@@ -2181,6 +2181,12 @@ class TerminalConsole(Console):
     def show_row_diff(
         self, row_diff: RowDiff, show_sample: bool = True, skip_grain_check: bool = False
     ) -> None:
+        if row_diff.empty:
+            self.console.print(
+                "\n[b][red]Neither the source nor the target table contained any records[/red][/b]"
+            )
+            return
+
         source_name = row_diff.source
         if row_diff.source_alias:
             source_name = row_diff.source_alias.upper()
diff --git a/sqlmesh/core/table_diff.py b/sqlmesh/core/table_diff.py
index e4b1271e..7b59acb9 100644
--- a/sqlmesh/core/table_diff.py
+++ b/sqlmesh/core/table_diff.py
@@ -83,6 +83,15 @@ class RowDiff(PydanticModel, frozen=True):
         """Count of the target."""
         return int(self.stats["t_count"])
 
+    @property
+    def empty(self) -> bool:
+        return (
+            self.source_count == 0
+            and self.target_count == 0
+            and self.s_only_count == 0
+            and self.t_only_count == 0
+        )
+
     @property
     def count_pct_change(self) -> float:
         """The percentage change of the counts."""
@@ -446,7 +455,7 @@ class TableDiff:
 
                 summary_query = exp.select(*summary_sums).from_(table)
 
-                stats_df = self.adapter.fetchdf(summary_query, quote_identifiers=True)
+                stats_df = self.adapter.fetchdf(summary_query, quote_identifiers=True).fillna(0)
                 stats_df["s_only_count"] = stats_df["s_count"] - stats_df["join_count"]
                 stats_df["t_only_count"] = stats_df["t_count"] - stats_df["join_count"]
                 stats = stats_df.iloc[0].to_dict()


Test patch:
diff --git a/tests/core/test_table_diff.py b/tests/core/test_table_diff.py
index e0bdbe51..7356e773 100644
--- a/tests/core/test_table_diff.py
+++ b/tests/core/test_table_diff.py
@@ -788,3 +788,44 @@ def test_data_diff_forward_only(sushi_context_fixed_date, capsys, caplog):
         assert row_diff.stats["distinct_count_t"] == 2
         assert row_diff.s_sample.shape == (2, 2)
         assert row_diff.t_sample.shape == (2, 2)
+
+
+def test_data_diff_empty_tables():
+    engine_adapter = DuckDBConnectionConfig().create_engine_adapter()
+
+    columns_to_types_src = {
+        "key": exp.DataType.build("int"),
+        "value": exp.DataType.build("varchar"),
+    }
+    columns_to_types_target = {
+        "key": exp.DataType.build("int"),
+        "value2": exp.DataType.build("varchar"),
+    }
+
+    engine_adapter.create_table("table_diff_source", columns_to_types_src)
+    engine_adapter.create_table("table_diff_target", columns_to_types_target)
+
+    table_diff = TableDiff(
+        adapter=engine_adapter,
+        source="table_diff_source",
+        target="table_diff_target",
+        source_alias="dev",
+        target_alias="prod",
+        on=["key"],
+    )
+
+    # should show the schema diff
+    schema_diff = table_diff.schema_diff()
+    assert len(schema_diff.added) == 1
+    assert schema_diff.added[0][0] == "value2"
+    assert len(schema_diff.removed) == 1
+    assert schema_diff.removed[0][0] == "value"
+
+    # should not error on the row diff
+    row_diff = table_diff.row_diff()
+    assert row_diff.empty
+
+    output = capture_console_output("show_row_diff", row_diff=row_diff)
+    assert (
+        strip_ansi_codes(output) == "Neither the source nor the target table contained any records"
+    )