Repository: dbt-labs/dbt-core

Issue:
[CT-40] [Bug] Using state:modified.body returns NoneType object has no attribute raw_sql
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

I am trying to use the `state:modified.body` selector which is returning the following error:
```
'NoneType' object has no attribute 'raw_sql'
```

It appears to be struggling with identifying body changes when the model didn't previously exist as its not able to pull the `raw_sql` to reference. 

### Expected Behavior

The `state:modified.body` selector should return all models whose body changes OR didn't previously exist.

### Steps To Reproduce

1. Compile project
2. Add an entirely new model
3. Run `dbt ls --select state:modified.body` which yields this error message

```
[error] [MainThread]: Encountered an error:
'NoneType' object has no attribute 'raw_sql'
```

However,  if I repeat the steps, but modify the intersection to be just `state:modified`, it returns the expected models.


### Relevant log output

_No response_

### Environment

```markdown
- OS:  Darwin-20.6.0-x86_64-i386-64bit (Mac OS Big Sur 11.5.2)
- Python: 3.7.8
- dbt: 1.0.0
```


### What database are you using dbt with?

snowflake

### Additional Context

_No response_


Discussion:
Hey @rayhondo, I reproduced this on my side. Working on fixing it now and the target release is 1.0.2. 
Just to confirm this happens only the first time you run `dbt ls --select state:modified.body --state target/` after the new model is added correct? For me the first time I run this command I got the same error. But if I run this command again the second time error is gone and I got `No nodes selected!`. Like this:
```
(env) ➜  toy dbt ls --select state:modified.body --state target/
22:56:51  Encountered an error:
'NoneType' object has no attribute 'raw_sql'
(env) ➜  toy dbt ls --select state:modified.body --state target/
22:56:55  The selection criterion 'state:modified.body' does not match any nodes
22:56:55  No nodes selected!
```
> Hey @rayhondo, I reproduced this on my side. Working on fixing it now and the target release is 1.0.2. Just to confirm this happens only the first time you run `dbt ls --select state:modified.body --state target/` after the new model is added correct? For me the first time I run this command I got the same error. But if I run this command again the second time error is gone and I got `No nodes selected!`. Like this:
> 
> ```
> (env) ➜  toy dbt ls --select state:modified.body --state target/
> 22:56:51  Encountered an error:
> 'NoneType' object has no attribute 'raw_sql'
> (env) ➜  toy dbt ls --select state:modified.body --state target/
> 22:56:55  The selection criterion 'state:modified.body' does not match any nodes
> 22:56:55  No nodes selected!
> ```

Yes, but that is undesired behavior since the body did change and was never picked up by the `state:modified.body` selector. We're using this in some "auto full refresh" automations and those tables would never get selected
> > Hey @rayhondo, I reproduced this on my side. Working on fixing it now and the target release is 1.0.2. Just to confirm this happens only the first time you run `dbt ls --select state:modified.body --state target/` after the new model is added correct? For me the first time I run this command I got the same error. But if I run this command again the second time error is gone and I got `No nodes selected!`. Like this:
> > ```
> > (env) ➜  toy dbt ls --select state:modified.body --state target/
> > 22:56:51  Encountered an error:
> > 'NoneType' object has no attribute 'raw_sql'
> > (env) ➜  toy dbt ls --select state:modified.body --state target/
> > 22:56:55  The selection criterion 'state:modified.body' does not match any nodes
> > 22:56:55  No nodes selected!
> > ```
> 
> Yes, but that is undesired behavior since the body did change and was never picked up by the `state:modified.body` selector. We're using this in some "auto full refresh" automations and those tables would never get selected

Hi Ray, thanks for the context! I realized that manifest in target gets refreshed every time you run `dbt ls`. I have the fix now just need to add some tests before it goes in. I will keep you posted.

Resolving patch:
diff --git a/core/dbt/graph/selector_methods.py b/core/dbt/graph/selector_methods.py
--- a/core/dbt/graph/selector_methods.py
+++ b/core/dbt/graph/selector_methods.py
@@ -1,7 +1,7 @@
 import abc
 from itertools import chain
 from pathlib import Path
-from typing import Set, List, Dict, Iterator, Tuple, Any, Union, Type, Optional
+from typing import Set, List, Dict, Iterator, Tuple, Any, Union, Type, Optional, Callable
 
 from dbt.dataclass_schema import StrEnum
 
@@ -478,42 +478,28 @@ def check_macros_modified(self, node):
             previous_macros = []
             return self.recursively_check_macros_modified(node, previous_macros)
 
-    def check_modified(self, old: Optional[SelectorTarget], new: SelectorTarget) -> bool:
+    # TODO check modifed_content and check_modified macro seems a bit redundent
+    def check_modified_content(self, old: Optional[SelectorTarget], new: SelectorTarget) -> bool:
         different_contents = not new.same_contents(old)  # type: ignore
         upstream_macro_change = self.check_macros_modified(new)
         return different_contents or upstream_macro_change
 
-    def check_modified_body(self, old: Optional[SelectorTarget], new: SelectorTarget) -> bool:
-        if hasattr(new, "same_body"):
-            return not new.same_body(old)  # type: ignore
-        else:
-            return False
-
-    def check_modified_configs(self, old: Optional[SelectorTarget], new: SelectorTarget) -> bool:
-        if hasattr(new, "same_config"):
-            return not new.same_config(old)  # type: ignore
-        else:
-            return False
-
-    def check_modified_persisted_descriptions(
-        self, old: Optional[SelectorTarget], new: SelectorTarget
-    ) -> bool:
-        if hasattr(new, "same_persisted_description"):
-            return not new.same_persisted_description(old)  # type: ignore
-        else:
-            return False
-
-    def check_modified_relation(
-        self, old: Optional[SelectorTarget], new: SelectorTarget
-    ) -> bool:
-        if hasattr(new, "same_database_representation"):
-            return not new.same_database_representation(old)  # type: ignore
-        else:
-            return False
-
     def check_modified_macros(self, _, new: SelectorTarget) -> bool:
         return self.check_macros_modified(new)
 
+    @staticmethod
+    def check_modified_factory(
+        compare_method: str
+    ) -> Callable[[Optional[SelectorTarget], SelectorTarget], bool]:
+        # get a function that compares two selector target based on compare method provided
+        def check_modified_things(old: Optional[SelectorTarget], new: SelectorTarget) -> bool:
+            if hasattr(new, compare_method):
+                # when old body does not exist or old and new are not the same
+                return not old or not getattr(new, compare_method)(old)  # type: ignore
+            else:
+                return False
+        return check_modified_things
+
     def check_new(self, old: Optional[SelectorTarget], new: SelectorTarget) -> bool:
         return old is None
 
@@ -527,14 +513,21 @@ def search(
 
         state_checks = {
             # it's new if there is no old version
-            'new': lambda old, _: old is None,
+            'new':
+                lambda old, _: old is None,
             # use methods defined above to compare properties of old + new
-            'modified': self.check_modified,
-            'modified.body': self.check_modified_body,
-            'modified.configs': self.check_modified_configs,
-            'modified.persisted_descriptions': self.check_modified_persisted_descriptions,
-            'modified.relation': self.check_modified_relation,
-            'modified.macros': self.check_modified_macros,
+            'modified':
+                self.check_modified_content,
+            'modified.body':
+                self.check_modified_factory('same_body'),
+            'modified.configs':
+                self.check_modified_factory('same_config'),
+            'modified.persisted_descriptions':
+                self.check_modified_factory('same_persisted_description'),
+            'modified.relation':
+                self.check_modified_factory('same_database_representation'),
+            'modified.macros':
+                self.check_modified_macros,
         }
         if selector in state_checks:
             checker = state_checks[selector]


Test patch:
diff --git a/test/unit/test_graph_selector_methods.py b/test/unit/test_graph_selector_methods.py
--- a/test/unit/test_graph_selector_methods.py
+++ b/test/unit/test_graph_selector_methods.py
@@ -835,6 +835,8 @@ def test_select_state_added_model(manifest, previous_state):
         manifest, method, 'modified') == {'another_model'}
     assert search_manifest_using_method(
         manifest, method, 'new') == {'another_model'}
+    assert search_manifest_using_method(
+        manifest, method, 'modified.body') == {'another_model'}
 
 
 def test_select_state_changed_model_sql(manifest, previous_state, view_model):