Repository: dbt-labs/dbt-core

Issue:
[CT-2378] [Bug] `state:modified` not catching breaking changes if model body also changes
If I change both the contract _and_ the body of a model with an enforced contract, `state:modified` selects the model without raising the exception added in #6869 / #7216.

### Repro case

```yml
models:
  - name: my_model
    config:
      contract:
        enforced: true
    columns:
      - name: id
        data_type: int
```

```sql
select 1 as id
```

```
$ mkdir state
$ dbt parse
$ mv target/manifest.json state/
```

If I change just the yaml to `data_type: string`:
```
$ dbt ls -s state:modified --state state/
19:17:06  Found 1 model, 0 tests, 0 snapshots, 1 analysis, 522 macros, 0 operations, 1 seed file, 0 sources, 0 exposures, 0 metrics, 0 groups
19:17:06  Running with dbt=1.5.0-b5
...
dbt.exceptions.ModelContractError: Contract Error in model my_model (models/my_model.sql)
  There is a breaking change in the model contract because column definitions have changed; you may need to create a new version. See: https://docs.getdbt.com/docs/collaborate/publish/model-versions
```

But if I also change the model's SQL to `select 1::text as id`, such that the contract _works_ again — but has still undergone a breaking change! — it's no longer being caught during the `state:modified` comparison:
```
$ dbt ls -s state:modified --state state/
19:17:26  Running with dbt=1.5.0-b5
19:17:26  Found 1 model, 0 tests, 0 snapshots, 1 analysis, 522 macros, 0 operations, 1 seed file, 0 sources, 0 exposures, 0 metrics, 0 groups
test.my_model
```

If I had to take a wild guess, this is because the model is first selected on the basis of the change to its `body`, before we get to the `same_contract` comparison?

---

If I had my druthers / as a UX improvement, the `ModelContractError` message would also make clear _what_ in the contract changed (similar to https://github.com/dbt-labs/dbt-core/issues/7209). In practice, the change would also be reflected in the `git diff`, which someone could inspect in order to understand the cause for the failing CI check.


Discussion:
> If I had to take a wild guess, this is because the model is first selected on the basis of the change to its `body`, before we get to the `same_contract` comparison?

Looks like yes:

https://github.com/dbt-labs/dbt-core/blob/050161c78f6d9acc36d0b76bd54ffcd778b7a467/core/dbt/contracts/graph/nodes.py#L429-L441

If I switch the order so that `self.same_contract(old)` comes first, before `self.same_body(old)`, then I see the exception I was expecting:
```
dbt.exceptions.ModelContractError: Contract Error in model my_model (models/my_model.sql)
  There is a breaking change in the model contract because column definitions have changed; you may need to create a new version. See: https://docs.getdbt.com/docs/collaborate/publish/model-versions
```

**Reason:** As soon as `same_body()` returns `False`, Python doesn't evaluate the rest of the methods in the `and`'ed conditional. (Right?)

Resolving patch:
diff --git a/core/dbt/contracts/graph/nodes.py b/core/dbt/contracts/graph/nodes.py
--- a/core/dbt/contracts/graph/nodes.py
+++ b/core/dbt/contracts/graph/nodes.py
@@ -430,13 +430,16 @@ def same_contents(self, old) -> bool:
         if old is None:
             return False
 
+        # Need to ensure that same_contract is called because it
+        # could throw an error
+        same_contract = self.same_contract(old)
         return (
             self.same_body(old)
             and self.same_config(old)
             and self.same_persisted_description(old)
             and self.same_fqn(old)
             and self.same_database_representation(old)
-            and self.same_contract(old)
+            and same_contract
             and True
         )
 


Test patch:
diff --git a/tests/functional/defer_state/test_modified_state.py b/tests/functional/defer_state/test_modified_state.py
--- a/tests/functional/defer_state/test_modified_state.py
+++ b/tests/functional/defer_state/test_modified_state.py
@@ -309,3 +309,62 @@ def test_changed_contract(self, project):
         write_file(schema_yml, "models", "schema.yml")
         with pytest.raises(ModelContractError):
             results = run_dbt(["run", "--models", "state:modified.contract", "--state", "./state"])
+
+
+my_model_sql = """
+select 1 as id
+"""
+
+modified_my_model_sql = """
+-- a comment
+select 1 as id
+"""
+
+my_model_yml = """
+models:
+  - name: my_model
+    config:
+      contract:
+        enforced: true
+    columns:
+      - name: id
+        data_type: int
+"""
+
+modified_my_model_yml = """
+models:
+  - name: my_model
+    config:
+      contract:
+        enforced: true
+    columns:
+      - name: id
+        data_type: string
+"""
+
+
+class TestModifiedBodyAndContract:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "my_model.sql": my_model_sql,
+            "my_model.yml": my_model_yml,
+        }
+
+    def copy_state(self):
+        if not os.path.exists("state"):
+            os.makedirs("state")
+        shutil.copyfile("target/manifest.json", "state/manifest.json")
+
+    def test_modified_body_and_contract(self, project):
+        results = run_dbt(["run"])
+        assert len(results) == 1
+        self.copy_state()
+
+        # Change both body and contract
+        write_file(modified_my_model_yml, "models", "my_model.yml")
+        write_file(modified_my_model_sql, "models", "my_model.sql")
+
+        # should raise even without specifying state:modified.contract
+        with pytest.raises(ModelContractError):
+            results = run_dbt(["run", "--models", "state:modified", "--state", "./state"])