Repository: dbt-labs/dbt-core

Issue:
Run tests IFF all first-order parents are selected
### Describe the feature
See prior discussion: #1827, #2132, misc `state:modified` conversations

Run a test only if _all_ first-order parents are included in the selection criteria. This would be most immediately relevant for:
- `relationships` tests
- data tests with multiple parents

This would also handle cases where a schema test has a property (`tag:`, `state:`) that its one parent model does not have. Since its single parent would not be included in the selection criteria, given the proposed condition, the test should not run.

### Describe alternatives you've considered
- How useful vs. confusing is this idea?
- Do we change default behavior? Or add a flag (`--all-parents`) to enable this?

### Who will this benefit?
Users who want finer-grained control over which tests they're running, especially:
- using complex selection syntax
- running in CI environments



Discussion:
I just ran into this in the following scenario for a slim CI build:

I have models `A <--- B` where B has a foreign key to A.

I also have a relationship test defined on model B that points to A.

I touch model A, so my slim CI build becomes `dbt run -m A`, followed by a `dbt test -m A`.

This fails since dbt selects the relationship test defined on model B because the relationship test depends on both A and B. 

dbt is selecting the test if ANY parent is selected, while I was looking for it run the test if ALL parents were selected.

Shouldn't running the test when **ALL** dependent nodes are selected be the default behaviour? 

I would much prefer to have this be the default and have a flag for the current behaviour rather than the other way around.

Are there some use-cases I'm missing that this would completely break?


**Workaround**
I had to  `dbt test --exclude test_name:relationships` which is not ideal.

Jeremy pointed out that I can use the `--defer` feature to fallback on prod, but that comes with its own challenges of state management.

@balmasi Thanks for the comment! A couple of thoughts here:

**Slim CI:** The reason we added support for `dbt test --defer` in dbt v0.19.0 is exactly for the reason you're describing: It often happens that, in a Slim CI run, you will have built one parent model of a multi-pronged test but not the other(s). The [defer](https://docs.getdbt.com/reference/node-selection/defer) functionality gives dbt the ability to "fail over" to the prod version of an upstream model if it's unbuilt/missing in the CI schema.

In my view, **relationships tests don't square with CI builds**. You're frequently working with a limited subset of data, whether that's a date cutoff or a random sample. The other three builtin dbt tests—`unique`, `not_null`, and `accepted_values`—are no likelier to fail on a subset vs. the complete dataset, but tests on referential integrity are _more_ likely to fail on arbitrary subsets. For that reason, I think `dbt test --exclude test_name:relationships` actually makes decent sense in a CI run.

> Shouldn't running the test when **ALL** dependent nodes are selected be the default behaviour?

I take your point here, and I'm sympathetic to this argument. This is something we've considered changing in the default behavior (#1827, #2132); I'm still figuring out how we should implement it in practice.

This isn't how selection works for other resource types: If you `dbt run -m model_a+`, that selects `model_c` even if `model_a` is not its only parent. Say `model_c` depends also on `model_b`: then `dbt run -m model_a+` in a fresh schema would fail on `model_c`, because `model_b` is missing. dbt introduced the `@` selection operation, so that you could `dbt run -m @model_a` and know that it would build whatever is needed.

Granted, test selection _is_ different from other resource selection because of the "magic jump" baked into it: when I test `model_a`, that _really_ means executing the tests that directly depend on `model_a`. In that sense, `dbt test -m model_a` is more like selecting `-m model_a+1`.

What's the appeal of that "magic jump"? It enables consistency of syntax between `test` and other commands, to enable things like `dbt run -m model_a && dbt test -m model_a`, or (someday) the same thing written as `dbt build -m model_a` (see #2743). We could modulate the magic, and make it _only_ select tests if all their parents are all the way present. That still gets us the consistent syntax we want, without any surprises.

In that world, if we had a relationships test between `model_a` and `model_b`:
1. `dbt test -m model_a` or `-m model_b`: Not selected. A parent is missing, so the "magic jump" doesn't take effect.
2. `dbt test -m model_a model_b`: Yes, selected. All parents are present, the "magic jump" says go for it!
3 `dbt test -m model_a+`, `-m model_a+1`, `-m @model_a`: Yes, selected. The test node is a child of `model_a` and directly included in the selection criteria; no "magic jump" necessary.

Today, all of the above select and execute that relationships test. In the first case, the intuitive syntax of the "magic jump" actually backfires, results in the unintuitive case you've encountered.

@drewbanin I'd be curious to get your thoughts here! I started this comment ready to say that we shouldn't do this... in the course of writing it out, I've managed to convince myself that it might just manage to thread the needle the way we'd want.
+1-ing this. In our use case, we need to do partial dbt runs *in production* in a separate environment due to sensitive (source) patient data in that environment. But we’d like to share the same models, so we have a monolithic project. So, some models just don’t run in this `sensitive` environment. 

We’d like to follow a blue/green deployment and test before we deploy our data, but the *selection of irrelevant relationship tests prevents this*. (more on whether this is truly *irrelevant* below)

So, to recap:

we run `dbt run -m source:sensitive+`, and then `dbt test -m source:sensitive+`; the latter fails because it selects relationship tests that *involve* `source:sensitive+`, and the ones where the parent is *not* in `source:sensitive+` fail.

I’m torn on whether this is fundamentally problematic or just requires configurability.

If there’s a relationship test, testing model `A` -(to)-> `B`, and run `dbt test -m B`, would I expect the relationship test to run? 

- `B` certainly partakes in the relationship, so rerunning it might have broken a relationship b/w `A` (if it exists) and `B`... so yes?
- But if `A` does not exist, I’m not sure I would fail the test - there’s no relationship to test, and that’s not a problem with `B`/that can be solved by changing `B`/rerunning `dbt run -m B`... and, it’s “true” that all foreign key values in `A` (of which there are none - feels like this is a `0` vs `null` dichotomy?) exist in `B`. But certainly would expect `dbt test -m A` to fail if `A` is the parent of the relationship.

I don’t think `dbt test --exclude test_name:relationship` is a viable workaround, as it throws the baby out with the bathwater - there are other legitimate relationship tests I *do* want to run (e.g. `B` -(to)-> `C` in the scenario above).

`defer`ing - I’m not very familiar with this, but “falling back” to another version of the model is not possible for us - there is no other version that exists.

The only two workarounds we’ve found: 
- hardcode the tests that should be excluded during these runs (unfortunate, because we now have to maintain dependencies elsewhere/it leaks knowledge about the dependency graph into our test command, creating divergence/likely missed test coverage in the future)
- or create dummy versions of our missing models during these runs (requiring some hacking up of those models/reduced maintainability).

Resolving patch:
diff --git a/core/dbt/graph/cli.py b/core/dbt/graph/cli.py
--- a/core/dbt/graph/cli.py
+++ b/core/dbt/graph/cli.py
@@ -26,7 +26,7 @@
 
 
 def parse_union(
-    components: List[str], expect_exists: bool
+    components: List[str], expect_exists: bool, greedy: bool = False
 ) -> SelectionUnion:
     # turn ['a b', 'c'] -> ['a', 'b', 'c']
     raw_specs = itertools.chain.from_iterable(
@@ -37,7 +37,7 @@ def parse_union(
     # ['a', 'b', 'c,d'] -> union('a', 'b', intersection('c', 'd'))
     for raw_spec in raw_specs:
         intersection_components: List[SelectionSpec] = [
-            SelectionCriteria.from_single_spec(part)
+            SelectionCriteria.from_single_spec(part, greedy=greedy)
             for part in raw_spec.split(INTERSECTION_DELIMITER)
         ]
         union_components.append(SelectionIntersection(
@@ -45,7 +45,6 @@ def parse_union(
             expect_exists=expect_exists,
             raw=raw_spec,
         ))
-
     return SelectionUnion(
         components=union_components,
         expect_exists=False,
@@ -54,21 +53,21 @@ def parse_union(
 
 
 def parse_union_from_default(
-    raw: Optional[List[str]], default: List[str]
+    raw: Optional[List[str]], default: List[str], greedy: bool = False
 ) -> SelectionUnion:
     components: List[str]
     expect_exists: bool
     if raw is None:
-        return parse_union(components=default, expect_exists=False)
+        return parse_union(components=default, expect_exists=False, greedy=greedy)
     else:
-        return parse_union(components=raw, expect_exists=True)
+        return parse_union(components=raw, expect_exists=True, greedy=greedy)
 
 
 def parse_difference(
     include: Optional[List[str]], exclude: Optional[List[str]]
 ) -> SelectionDifference:
     included = parse_union_from_default(include, DEFAULT_INCLUDES)
-    excluded = parse_union_from_default(exclude, DEFAULT_EXCLUDES)
+    excluded = parse_union_from_default(exclude, DEFAULT_EXCLUDES, greedy=True)
     return SelectionDifference(components=[included, excluded])
 
 
diff --git a/core/dbt/graph/selector.py b/core/dbt/graph/selector.py
--- a/core/dbt/graph/selector.py
+++ b/core/dbt/graph/selector.py
@@ -1,5 +1,5 @@
 
-from typing import Set, List, Optional
+from typing import Set, List, Optional, Tuple
 
 from .graph import Graph, UniqueId
 from .queue import GraphQueue
@@ -30,6 +30,18 @@ def alert_non_existence(raw_spec, nodes):
         )
 
 
+def can_select_indirectly(node):
+    """If a node is not selected itself, but its parent(s) are, it may qualify
+    for indirect selection.
+    Today, only Test nodes can be indirectly selected. In the future,
+    other node types or invocation flags might qualify.
+    """
+    if node.resource_type == NodeType.Test:
+        return True
+    else:
+        return False
+
+
 class NodeSelector(MethodManager):
     """The node selector is aware of the graph and manifest,
     """
@@ -61,8 +73,8 @@ def select_included(
 
     def get_nodes_from_criteria(
         self,
-        spec: SelectionCriteria,
-    ) -> Set[UniqueId]:
+        spec: SelectionCriteria
+    ) -> Tuple[Set[UniqueId], Set[UniqueId]]:
         """Get all nodes specified by the single selection criteria.
 
         - collect the directly included nodes
@@ -79,11 +91,14 @@ def get_nodes_from_criteria(
                 f"The '{spec.method}' selector specified in {spec.raw} is "
                 f"invalid. Must be one of [{valid_selectors}]"
             )
-            return set()
+            return set(), set()
 
-        extras = self.collect_specified_neighbors(spec, collected)
-        result = self.expand_selection(collected | extras)
-        return result
+        neighbors = self.collect_specified_neighbors(spec, collected)
+        direct_nodes, indirect_nodes = self.expand_selection(
+            selected=(collected | neighbors),
+            greedy=spec.greedy
+        )
+        return direct_nodes, indirect_nodes
 
     def collect_specified_neighbors(
         self, spec: SelectionCriteria, selected: Set[UniqueId]
@@ -106,24 +121,46 @@ def collect_specified_neighbors(
             additional.update(self.graph.select_children(selected, depth))
         return additional
 
-    def select_nodes(self, spec: SelectionSpec) -> Set[UniqueId]:
-        """Select the nodes in the graph according to the spec.
-
-        If the spec is a composite spec (a union, difference, or intersection),
+    def select_nodes_recursively(self, spec: SelectionSpec) -> Tuple[Set[UniqueId], Set[UniqueId]]:
+        """If the spec is a composite spec (a union, difference, or intersection),
         recurse into its selections and combine them. If the spec is a concrete
         selection criteria, resolve that using the given graph.
         """
         if isinstance(spec, SelectionCriteria):
-            result = self.get_nodes_from_criteria(spec)
+            direct_nodes, indirect_nodes = self.get_nodes_from_criteria(spec)
         else:
-            node_selections = [
-                self.select_nodes(component)
+            bundles = [
+                self.select_nodes_recursively(component)
                 for component in spec
             ]
-            result = spec.combined(node_selections)
+
+            direct_sets = []
+            indirect_sets = []
+
+            for direct, indirect in bundles:
+                direct_sets.append(direct)
+                indirect_sets.append(direct | indirect)
+
+            initial_direct = spec.combined(direct_sets)
+            indirect_nodes = spec.combined(indirect_sets)
+
+            direct_nodes = self.incorporate_indirect_nodes(initial_direct, indirect_nodes)
+
             if spec.expect_exists:
-                alert_non_existence(spec.raw, result)
-        return result
+                alert_non_existence(spec.raw, direct_nodes)
+
+        return direct_nodes, indirect_nodes
+
+    def select_nodes(self, spec: SelectionSpec) -> Set[UniqueId]:
+        """Select the nodes in the graph according to the spec.
+
+        This is the main point of entry for turning a spec into a set of nodes:
+        - Recurse through spec, select by criteria, combine by set operation
+        - Return final (unfiltered) selection set
+        """
+
+        direct_nodes, indirect_nodes = self.select_nodes_recursively(spec)
+        return direct_nodes
 
     def _is_graph_member(self, unique_id: UniqueId) -> bool:
         if unique_id in self.manifest.sources:
@@ -162,12 +199,55 @@ def filter_selection(self, selected: Set[UniqueId]) -> Set[UniqueId]:
             unique_id for unique_id in selected if self._is_match(unique_id)
         }
 
-    def expand_selection(self, selected: Set[UniqueId]) -> Set[UniqueId]:
-        """Perform selector-specific expansion."""
+    def expand_selection(
+        self, selected: Set[UniqueId], greedy: bool = False
+    ) -> Tuple[Set[UniqueId], Set[UniqueId]]:
+        # Test selection can expand to include an implicitly/indirectly selected test.
+        # In this way, `dbt test -m model_a` also includes tests that directly depend on `model_a`.
+        # Expansion has two modes, GREEDY and NOT GREEDY.
+        #
+        # GREEDY mode: If ANY parent is selected, select the test. We use this for EXCLUSION.
+        #
+        # NOT GREEDY mode:
+        #  - If ALL parents are selected, select the test.
+        #  - If ANY parent is missing, return it separately. We'll keep it around
+        #    for later and see if its other parents show up.
+        # We use this for INCLUSION.
+
+        direct_nodes = set(selected)
+        indirect_nodes = set()
+
+        for unique_id in self.graph.select_successors(selected):
+            if unique_id in self.manifest.nodes:
+                node = self.manifest.nodes[unique_id]
+                if can_select_indirectly(node):
+                    # should we add it in directly?
+                    if greedy or set(node.depends_on.nodes) <= set(selected):
+                        direct_nodes.add(unique_id)
+                    # if not:
+                    else:
+                        indirect_nodes.add(unique_id)
+
+        return direct_nodes, indirect_nodes
+
+    def incorporate_indirect_nodes(
+        self, direct_nodes: Set[UniqueId], indirect_nodes: Set[UniqueId] = set()
+    ) -> Set[UniqueId]:
+        # Check tests previously selected indirectly to see if ALL their
+        # parents are now present.
+
+        selected = set(direct_nodes)
+
+        for unique_id in indirect_nodes:
+            if unique_id in self.manifest.nodes:
+                node = self.manifest.nodes[unique_id]
+                if set(node.depends_on.nodes) <= set(selected):
+                    selected.add(unique_id)
+
         return selected
 
     def get_selected(self, spec: SelectionSpec) -> Set[UniqueId]:
-        """get_selected runs trhough the node selection process:
+        """get_selected runs through the node selection process:
 
             - node selection. Based on the include/exclude sets, the set
                 of matched unique IDs is returned
diff --git a/core/dbt/graph/selector_spec.py b/core/dbt/graph/selector_spec.py
--- a/core/dbt/graph/selector_spec.py
+++ b/core/dbt/graph/selector_spec.py
@@ -66,6 +66,7 @@ class SelectionCriteria:
     parents_depth: Optional[int]
     children: bool
     children_depth: Optional[int]
+    greedy: bool = False
 
     def __post_init__(self):
         if self.children and self.childrens_parents:
@@ -103,7 +104,7 @@ def parse_method(
 
     @classmethod
     def selection_criteria_from_dict(
-        cls, raw: Any, dct: Dict[str, Any]
+        cls, raw: Any, dct: Dict[str, Any], greedy: bool = False
     ) -> 'SelectionCriteria':
         if 'value' not in dct:
             raise RuntimeException(
@@ -123,10 +124,11 @@ def selection_criteria_from_dict(
             parents_depth=parents_depth,
             children=bool(dct.get('children')),
             children_depth=children_depth,
+            greedy=greedy
         )
 
     @classmethod
-    def dict_from_single_spec(cls, raw: str):
+    def dict_from_single_spec(cls, raw: str, greedy: bool = False):
         result = RAW_SELECTOR_PATTERN.match(raw)
         if result is None:
             return {'error': 'Invalid selector spec'}
@@ -146,13 +148,13 @@ def dict_from_single_spec(cls, raw: str):
         return dct
 
     @classmethod
-    def from_single_spec(cls, raw: str) -> 'SelectionCriteria':
+    def from_single_spec(cls, raw: str, greedy: bool = False) -> 'SelectionCriteria':
         result = RAW_SELECTOR_PATTERN.match(raw)
         if result is None:
             # bad spec!
             raise RuntimeException(f'Invalid selector spec "{raw}"')
 
-        return cls.selection_criteria_from_dict(raw, result.groupdict())
+        return cls.selection_criteria_from_dict(raw, result.groupdict(), greedy=greedy)
 
 
 class BaseSelectionGroup(Iterable[SelectionSpec], metaclass=ABCMeta):


Test patch:
diff --git a/core/dbt/task/test.py b/core/dbt/task/test.py
--- a/core/dbt/task/test.py
+++ b/core/dbt/task/test.py
@@ -1,5 +1,5 @@
 import threading
-from typing import Dict, Any, Set, Union
+from typing import Dict, Any, Union
 
 from .compile import CompileRunner
 from .run import RunTask
@@ -21,7 +21,6 @@
 from dbt.graph import (
     ResourceTypeSelector,
     SelectionSpec,
-    UniqueId,
     parse_test_selectors,
 )
 from dbt.node_types import NodeType, RunHookType
@@ -126,17 +125,6 @@ def __init__(self, graph, manifest, previous_state):
             resource_types=[NodeType.Test],
         )
 
-    def expand_selection(self, selected: Set[UniqueId]) -> Set[UniqueId]:
-        # exposures can't have tests, so this is relatively easy
-        selected_tests = set()
-        for unique_id in self.graph.select_successors(selected):
-            if unique_id in self.manifest.nodes:
-                node = self.manifest.nodes[unique_id]
-                if node.resource_type == NodeType.Test:
-                    selected_tests.add(unique_id)
-
-        return selected | selected_tests
-
 
 class TestTask(RunTask):
     """
diff --git a/test/integration/007_graph_selection_tests/test_graph_selection.py b/test/integration/007_graph_selection_tests/test_graph_selection.py
--- a/test/integration/007_graph_selection_tests/test_graph_selection.py
+++ b/test/integration/007_graph_selection_tests/test_graph_selection.py
@@ -384,12 +384,13 @@ def test__postgres__exposure_parents(self):
         assert sorted(results) == ['exposure:test.seed_ml_exposure', 'source:test.raw.seed']
 
         results = self.run_dbt(['ls', '--select', '1+exposure:user_exposure'])
-        assert len(results) == 3
-        assert sorted(results) == ['exposure:test.user_exposure', 'test.users', 'test.users_rollup']
+        assert len(results) == 5
+        assert sorted(results) == ['exposure:test.user_exposure', 'test.schema_test.unique_users_id', 
+            'test.schema_test.unique_users_rollup_gender', 'test.users', 'test.users_rollup']
 
-        self.run_dbt(['run', '-m', '+exposure:user_exposure'])
+        results = self.run_dbt(['run', '-m', '+exposure:user_exposure'])
         # users, users_rollup
-        assert len(results) == 3
+        assert len(results) == 2
 
         created_models = self.get_models_in_schema()
         self.assertIn('users_rollup', created_models)
diff --git a/test/integration/066_test_selection_tests/models/model_a.sql b/test/integration/066_test_selection_tests/models/model_a.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/066_test_selection_tests/models/model_a.sql
@@ -0,0 +1,5 @@
+{{ config(
+    tags = ['a_or_b']
+) }}
+
+select 1 as fun
diff --git a/test/integration/066_test_selection_tests/models/model_b.sql b/test/integration/066_test_selection_tests/models/model_b.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/066_test_selection_tests/models/model_b.sql
@@ -0,0 +1,5 @@
+{{ config(
+    tags = ['a_or_b']
+) }}
+
+select 1 as fun
diff --git a/test/integration/066_test_selection_tests/models/schema.yml b/test/integration/066_test_selection_tests/models/schema.yml
new file mode 100644
--- /dev/null
+++ b/test/integration/066_test_selection_tests/models/schema.yml
@@ -0,0 +1,27 @@
+version: 2
+
+sources:
+  - name: my_src
+    schema: "{{ target.schema }}"
+    tables:
+      - name: my_tbl
+        identifier: model_b
+        columns:
+          - name: fun
+            tests:
+              - unique
+
+models:
+  - name: model_a
+    columns:
+      - name: fun
+        tags: [column_level_tag]
+        tests:
+          - unique
+          - relationships:
+              to: ref('model_b')
+              field: fun
+              tags: [test_level_tag]
+          - relationships:
+              to: source('my_src', 'my_tbl')
+              field: fun
diff --git a/test/integration/066_test_selection_tests/test_selection_expansion.py b/test/integration/066_test_selection_tests/test_selection_expansion.py
new file mode 100644
--- /dev/null
+++ b/test/integration/066_test_selection_tests/test_selection_expansion.py
@@ -0,0 +1,230 @@
+from test.integration.base import DBTIntegrationTest, FakeArgs, use_profile
+
+from dbt.task.test import TestTask
+from dbt.task.list import ListTask
+
+
+class TestSelectionExpansion(DBTIntegrationTest):
+    @property
+    def schema(self):
+        return "test_selection_expansion_065"
+
+    @property
+    def models(self):
+        return "models"
+
+    @property
+    def project_config(self):
+        return {
+            "config-version": 2,
+            "test-paths": ["tests"]
+        }
+
+    def list_tests_and_assert(self, include, exclude, expected_tests):
+        list_args = [ 'ls', '--resource-type', 'test']
+        if include:
+            list_args.extend(('--select', include))
+        if exclude:
+            list_args.extend(('--exclude', exclude))
+        
+        listed = self.run_dbt(list_args)
+        assert len(listed) == len(expected_tests)
+        
+        test_names = [name.split('.')[2] for name in listed]
+        assert sorted(test_names) == sorted(expected_tests)
+
+    def run_tests_and_assert(
+        self, include, exclude, expected_tests, schema = False, data = False
+    ):
+        results = self.run_dbt(['run'])
+        self.assertEqual(len(results), 2)
+         
+        test_args = ['test']
+        if include:
+            test_args.extend(('--models', include))
+        if exclude:
+            test_args.extend(('--exclude', exclude))
+        if schema:
+            test_args.append('--schema')
+        if data:
+            test_args.append('--data')
+        
+        results = self.run_dbt(test_args)
+        tests_run = [r.node.name for r in results]
+        assert len(tests_run) == len(expected_tests)
+        
+        assert sorted(tests_run) == sorted(expected_tests)
+
+    @use_profile('postgres')
+    def test__postgres__all_tests_no_specifiers(self):
+        select = None
+        exclude = None
+        expected = [
+            'cf_a_b', 'cf_a_src', 'just_a',
+            'relationships_model_a_fun__fun__ref_model_b_',
+            'relationships_model_a_fun__fun__source_my_src_my_tbl_',
+            'source_unique_my_src_my_tbl_fun',
+            'unique_model_a_fun'
+        ]
+            
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+
+    @use_profile('postgres')
+    def test__postgres__model_a_alone(self):
+        select = 'model_a'
+        exclude = None
+        expected = ['just_a','unique_model_a_fun']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+
+    @use_profile('postgres')
+    def test__postgres__model_a_model_b(self):
+        select = 'model_a model_b'
+        exclude = None
+        expected = [
+            'cf_a_b','just_a','unique_model_a_fun',
+            'relationships_model_a_fun__fun__ref_model_b_'
+        ]
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+
+    @use_profile('postgres')
+    def test__postgres__model_a_sources(self):
+        select = 'model_a source:*'
+        exclude = None
+        expected = [
+            'cf_a_src','just_a','unique_model_a_fun',
+            'source_unique_my_src_my_tbl_fun',
+            'relationships_model_a_fun__fun__source_my_src_my_tbl_'
+        ]
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+    
+    @use_profile('postgres')
+    def test__postgres__exclude_model_b(self):
+        select = None
+        exclude = 'model_b'
+        expected = [
+            'cf_a_src', 'just_a',
+            'relationships_model_a_fun__fun__source_my_src_my_tbl_',
+            'source_unique_my_src_my_tbl_fun','unique_model_a_fun'
+        ]
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+
+    @use_profile('postgres')
+    def test__postgres__model_a_exclude_specific_test(self):
+        select = 'model_a'
+        exclude = 'unique_model_a_fun'
+        expected = ['just_a']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+
+    @use_profile('postgres')
+    def test__postgres__only_schema(self):
+        select = 'test_type:schema'
+        exclude = None
+        expected = [
+            'relationships_model_a_fun__fun__ref_model_b_',
+            'relationships_model_a_fun__fun__source_my_src_my_tbl_',
+            'source_unique_my_src_my_tbl_fun',
+            'unique_model_a_fun'
+        ]
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(None, exclude, expected, schema=True)
+
+    @use_profile('postgres')
+    def test__postgres__model_a_only_data(self):
+        select = 'model_a,test_type:schema'
+        exclude = None
+        expected = ['unique_model_a_fun']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert('model_a', exclude, expected, schema=True)
+
+    @use_profile('postgres')
+    def test__postgres__only_data(self):
+        select = 'test_type:data'
+        exclude = None
+        expected = ['cf_a_b', 'cf_a_src', 'just_a']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(None, exclude, expected, data=True)
+        
+    @use_profile('postgres')
+    def test__postgres__model_a_only_data(self):
+        select = 'model_a,test_type:data'
+        exclude = None
+        expected = ['just_a']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert('model_a', exclude, expected, data=True)
+
+    @use_profile('postgres')
+    def test__postgres__test_name_intersection(self):
+        select = 'model_a,test_name:unique'
+        exclude = None
+        expected = ['unique_model_a_fun']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+    
+    @use_profile('postgres')
+    def test__postgres__model_tag_test_name_intersection(self):
+        select = 'tag:a_or_b,test_name:relationships'
+        exclude = None
+        expected = ['relationships_model_a_fun__fun__ref_model_b_']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+
+    @use_profile('postgres')
+    def test__postgres__select_column_level_tag(self):
+        select = 'tag:column_level_tag'
+        exclude = None
+        expected = [
+            'relationships_model_a_fun__fun__ref_model_b_',
+            'relationships_model_a_fun__fun__source_my_src_my_tbl_',
+            'unique_model_a_fun'
+        ]
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+
+    @use_profile('postgres')
+    def test__postgres__exclude_column_level_tag(self):
+        select = None
+        exclude = 'tag:column_level_tag'
+        expected = ['cf_a_b','cf_a_src','just_a','source_unique_my_src_my_tbl_fun']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+
+    @use_profile('postgres')
+    def test__postgres__test_level_tag(self):
+        select = 'tag:test_level_tag'
+        exclude = None
+        expected = ['relationships_model_a_fun__fun__ref_model_b_']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
+
+    @use_profile('postgres')
+    def test__postgres__exclude_data_test_tag(self):
+        select = 'model_a'
+        exclude = 'tag:data_test_tag'
+        expected = ['unique_model_a_fun']
+        
+        self.list_tests_and_assert(select, exclude, expected)
+        self.run_tests_and_assert(select, exclude, expected)
diff --git a/test/integration/066_test_selection_tests/tests/cf_a_b.sql b/test/integration/066_test_selection_tests/tests/cf_a_b.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/066_test_selection_tests/tests/cf_a_b.sql
@@ -0,0 +1,3 @@
+select * from {{ ref('model_a') }}
+cross join {{ ref('model_b') }}
+where false
diff --git a/test/integration/066_test_selection_tests/tests/cf_a_src.sql b/test/integration/066_test_selection_tests/tests/cf_a_src.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/066_test_selection_tests/tests/cf_a_src.sql
@@ -0,0 +1,3 @@
+select * from {{ ref('model_a') }}
+cross join {{ source('my_src', 'my_tbl') }}
+where false
diff --git a/test/integration/066_test_selection_tests/tests/just_a.sql b/test/integration/066_test_selection_tests/tests/just_a.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/066_test_selection_tests/tests/just_a.sql
@@ -0,0 +1,4 @@
+{{ config(tags = ['data_test_tag']) }}
+
+select * from {{ ref('model_a') }}
+where false