Repository: dbt-labs/dbt-core

Issue:
[CT-231] [Bug] Macro change not picked up by state:modified+ if macro is invoked after an unchanged macro in the model code
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

In dbt core version 1.0.0, if you reference an unchanged macro in model code before referencing the changed macro, the macro change will not be picked up by the state:modified+ flag. For example, if I have 2 macros:

```
{% macro unchanged_macro() %}
1 as column2


{% endmacro %}
```

and 

```
{% macro changed_macro() %}
2 as column1


{% endmacro %}
```

Referenced in 2 different models like so:

```
WITH my_test AS (
    SELECT 
    
     {{ changed_macro() }},
     {{ unchanged_macro() }}
)

SELECT * from my_test
```

and the second model is:
```
WITH my_test AS (
    SELECT 

     {{ unchanged_macro() }},
     {{ changed_macro() }}
)

SELECT * from my_test
```


and then make a change and pull request against the macro 'changed macro', only the first model will run when invoked by:
`dbt build --select state:modified+`

This looks to be due to the order in which the macros are called in the model.

### Expected Behavior

both models are identified as modified, regardless of the order in which macros are called in the model code

### Steps To Reproduce

1. with a project on dbt core 1.0.0, create 2 macros: 
```
{% macro unchanged_macro() %}
1 as column2


{% endmacro **%}
```
and:
```
{% macro changed_macro() %}
2 as column1


{% endmacro %}
```

2. also create 2 models that call those macros:

```
WITH my_test AS (
    SELECT 

     {{ unchanged_macro() }},
     {{ changed_macro() }}
)

SELECT * from my_test
```

and:

```
WITH my_test AS (
    SELECT 
    
     {{ changed_macro() }},
     {{ unchanged_macro() }}
)

SELECT * from my_test
```

3. merge all changes so far
4. create a CI/CD job with a state:modified+ command, like so: `dbt build --select state:modified+`
5. modify the macro 'changed_macro' such as adding another column like so: 
```
{% macro changed_macro() %}
2 as column1,
3 as column3


{% endmacro %}
```
6. look at the run logs for the CI/CD job, and see that only the model that calls the changed_macro first is identified and run
<img width="1621" alt="Screenshot 2022-02-04 at 1 57 56 p m" src="https://user-images.githubusercontent.com/40356588/152541390-49312c77-6630-4dc2-9e29-88b145f45d69.png">




### Relevant log output

```shell
console logs:


13:36:14  [[33mWARNING[0m]: Deprecated functionality
The `source-paths` config has been renamed to `model-paths`. Please update your
`dbt_project.yml` configuration to reflect this change.
13:36:14  [[33mWARNING[0m]: Deprecated functionality
The `data-paths` config has been renamed to `seed-paths`. Please update your
`dbt_project.yml` configuration to reflect this change.
13:36:14  Running with dbt=1.0.1
13:36:14  Partial parse save file not found. Starting full parse.
13:36:15  Found 9 models, 4 tests, 0 snapshots, 1 analysis, 368 macros, 0 operations, 0 seed files, 0 sources, 0 exposures, 0 metrics
13:36:15  
13:36:18  Concurrency: 4 threads (target='default')
13:36:18  
13:36:18  1 of 1 START view model dbt_cloud_pr_45850_24.macro_test........................ [RUN]
13:36:19  1 of 1 OK created view model dbt_cloud_pr_45850_24.macro_test................... [[32mSUCCESS 1[0m in 1.21s]
13:36:19  
13:36:19  Finished running 1 view model in 4.23s.
13:36:19  
13:36:19  [32mCompleted successfully[0m
13:36:19  
13:36:19  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1
```
```


### Environment

```markdown
dbt Cloud
- dbt: 1.0.0 latest
```


### What database are you using dbt with?

snowflake

### Additional Context

_No response_


Discussion:
Thanks for the detailed writeup @saraleon1!

I was able to reproduce this locally. Both models show both macros in their `depends_on.macros` in `manifest.json`. After changing `changed_macro`, however, I see only:
```
$ dbt ls -s state:modified --state state/
testy.model_a
```

I'm pretty sure the problem lies in the recursive logic for this method (no surprise, I wrote this code):

https://github.com/dbt-labs/dbt-core/blob/f3c7b6bfd158df9cda3077a090f430fb9cd6e147/core/dbt/graph/selector_methods.py#L452-L467

Namely, if the first macro doesn't have any dependencies of its own, we `return False` (and break the loop) rather than continuing to loop through the initial set of `node.depends_on.macros`. If I change those last few lines to:
```python
            if len(macro_node.depends_on.macros) > 0:
                return self.recursively_check_macros_modified(macro_node, previous_macros)
            elif len(node.depends_on.macros) > len(previous_macros):
                continue
            else:
                return False
```

This returns the correct result:
```
$ dbt ls -s state:modified --state state/
testy.model_a
testy.model_b
```

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
@@ -414,20 +414,24 @@ def _macros_modified(self) -> List[str]:
 
         return modified
 
-    def recursively_check_macros_modified(self, node, previous_macros):
+    def recursively_check_macros_modified(self, node, visited_macros):
         # loop through all macros that this node depends on
         for macro_uid in node.depends_on.macros:
             # avoid infinite recursion if we've already seen this macro
-            if macro_uid in previous_macros:
+            if macro_uid in visited_macros:
                 continue
-            previous_macros.append(macro_uid)
+            visited_macros.append(macro_uid)
             # is this macro one of the modified macros?
             if macro_uid in self.modified_macros:
                 return True
             # if not, and this macro depends on other macros, keep looping
             macro_node = self.manifest.macros[macro_uid]
             if len(macro_node.depends_on.macros) > 0:
-                return self.recursively_check_macros_modified(macro_node, previous_macros)
+                return self.recursively_check_macros_modified(macro_node, visited_macros)
+            # this macro hasn't been modified, but we haven't checked
+            # the other macros the node depends on, so keep looking
+            elif len(node.depends_on.macros) > len(visited_macros):
+                continue
             else:
                 return False
 
@@ -440,8 +444,8 @@ def check_macros_modified(self, node):
             return False
         # recursively loop through upstream macros to see if any is modified
         else:
-            previous_macros = []
-            return self.recursively_check_macros_modified(node, previous_macros)
+            visited_macros = []
+            return self.recursively_check_macros_modified(node, visited_macros)
 
     # TODO check modifed_content and check_modified macro seems a bit redundent
     def check_modified_content(self, old: Optional[SelectorTarget], new: SelectorTarget) -> bool:


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
@@ -44,7 +44,7 @@
 from .utils import replace_config
 
 
-def make_model(pkg, name, sql, refs=None, sources=None, tags=None, path=None, alias=None, config_kwargs=None, fqn_extras=None):
+def make_model(pkg, name, sql, refs=None, sources=None, tags=None, path=None, alias=None, config_kwargs=None, fqn_extras=None, depends_on_macros=None):
     if refs is None:
         refs = []
     if sources is None:
@@ -57,6 +57,8 @@ def make_model(pkg, name, sql, refs=None, sources=None, tags=None, path=None, al
         alias = name
     if config_kwargs is None:
         config_kwargs = {}
+    if depends_on_macros is None:
+        depends_on_macros = []
 
     if fqn_extras is None:
         fqn_extras = []
@@ -89,7 +91,10 @@ def make_model(pkg, name, sql, refs=None, sources=None, tags=None, path=None, al
         tags=tags,
         refs=ref_values,
         sources=source_values,
-        depends_on=DependsOn(nodes=depends_on_nodes, macros=[]),
+        depends_on=DependsOn(
+            nodes=depends_on_nodes,
+            macros=depends_on_macros,
+        ),
         resource_type=NodeType.Model,
         checksum=FileHash.from_contents(''),
     )
@@ -791,6 +796,10 @@ def add_node(manifest, node):
     manifest.nodes[node.unique_id] = node
 
 
+def add_macro(manifest, macro):
+    manifest.macros[macro.unique_id] = macro
+
+
 def change_node(manifest, node, change=None):
     if change is not None:
         node = change(node)
@@ -1035,3 +1044,30 @@ def test_select_state_changed_test_macro_sql(manifest, previous_state, macro_def
     assert search_manifest_using_method(
         manifest, method, 'modified.macros') == {'not_null_table_model_id'}
     assert not search_manifest_using_method(manifest, method, 'new')
+
+def test_select_state_changed_test_macros(manifest, previous_state):
+    changed_macro = make_macro('dbt', 'changed_macro', 'blablabla')
+    add_macro(manifest, changed_macro)
+    add_macro(previous_state.manifest, changed_macro.replace(macro_sql='something different'))
+
+    unchanged_macro = make_macro('dbt', 'unchanged_macro', 'blablabla')
+    add_macro(manifest, unchanged_macro)
+    add_macro(previous_state.manifest, unchanged_macro)
+
+    model1 = make_model('dbt', 'model1', 'blablabla',
+            depends_on_macros=[changed_macro.unique_id, unchanged_macro.unique_id])
+    add_node(manifest, model1)
+    add_node(previous_state.manifest, model1)
+
+    model2 = make_model('dbt', 'model2', 'blablabla',
+            depends_on_macros=[unchanged_macro.unique_id, changed_macro.unique_id])
+    add_node(manifest, model2)
+    add_node(previous_state.manifest, model2)
+
+    method = statemethod(manifest, previous_state)
+
+    assert search_manifest_using_method(
+        manifest, method, 'modified') == {'model1', 'model2'}
+    assert search_manifest_using_method(
+        manifest, method, 'modified.macros') == {'model1', 'model2'}
+    assert not search_manifest_using_method(manifest, method, 'new')