Repository: dbt-labs/dbt-core

Issue:
Model intersection syntax
### Describe the feature
Currently, when you provide two args to the model selection syntax, dbt selects the union of the two parts of the graph:
e.g.
```
dbt run --models tag:hourly tag:marketing
```
would run all models that have either the `hourly` or `marketing` tag (or both)

I'd like a way to only run the intersection of these two parts of the graph:
```
dbt run --models [tag:hourly ∩ tag:marketing ]
```
That would only run models that have both the `hourly`AND `marketing` tag. And of course `∩` is a terrible symbol to use here.

### Describe alternatives you've considered
We've seen this come up a few times, normally when people want to run the models in a folder that have a certain tag. Our current advice is to add new tags.



Discussion:
Let's prioritize this for the Octavius Catto release. We should use a `,` to separate selectors, eg:

```
dbt run --models tag:abc,tag:def tag:ghi,jkl
```

This will select models that have:
 - tags abc AND def, OR
 - tags ghi AND jkl


do you mean:
```
dbt run --models tag:abc,tag:def tag:ghi,tag:jkl
```

FWIW, I actually kinda like the parentheses approach:
```
dbt run --models [tag:abc tag:def] [tag:ghi tag:jkl]
```
It's easier for me to see what's nested together. But I don't know what the norm is here/whether parens are a bad idea for a CLI arg!
Anyone who's in a rush solving this (myself included) can use a bit of shell and jq to solve this when invoking `dbt run`.
```
dbt run -m $(dbt ls --resource-type model --output json | jq '. | select(.tags | index("abc")) | select(.tags | index("def")) .name' -r | tr '\n' ' ' && echo '""')
```

Edit: Added `&& echo '""'` to ensure the statement returns at least one model (`""`). 
@Raalsky I think you indicated that you were interested in picking this up -- is that still the case? If not, we're ready to prioritize this on our end :)
@drewbanin Sorry for being off. Yeah, I'm still interested. I'll post updates after the weekend, ok? 

Resolving patch:
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
@@ -16,6 +16,8 @@
 SELECTOR_GLOB = '*'
 SELECTOR_CHILDREN_AND_ANCESTORS = '@'
 SELECTOR_DELIMITER = ':'
+SPEC_DELIMITER = ' '
+INTERSECTION_DELIMITER = ','
 
 
 def _probably_path(value: str):
@@ -69,6 +71,10 @@ def __init__(self, node_spec: str):
                 self.selector_type = SELECTOR_FILTERS.FQN
 
 
+def split_intersection_blocks(spec):
+    return spec.split(INTERSECTION_DELIMITER)
+
+
 class SELECTOR_FILTERS(str, Enum):
     FQN = 'fqn'
     TAG = 'tag'
@@ -90,7 +96,7 @@ def alert_non_existence(raw_spec, nodes):
 def split_specs(node_specs: Iterable[str]):
     specs: Set[str] = set()
     for spec in node_specs:
-        parts = spec.split(" ")
+        parts = spec.split(SPEC_DELIMITER)
         specs.update(parts)
 
     return specs
@@ -388,6 +394,13 @@ def get_nodes_from_spec(self, graph, spec):
 
         return collected
 
+    def get_nodes_from_intersection_spec(self, graph, raw_spec):
+        return set.intersection(
+            *[self.get_nodes_from_spec(graph, SelectionCriteria(
+                intersection_block_spec)) for intersection_block_spec in
+              split_intersection_blocks(raw_spec)]
+        )
+
     def get_nodes_from_multiple_specs(
             self,
             graph,
@@ -400,8 +413,7 @@ def get_nodes_from_multiple_specs(
         operator = set.difference_update if exclude else set.update
 
         for raw_spec in split_specs(specs):
-            spec = SelectionCriteria(raw_spec)
-            nodes = self.get_nodes_from_spec(graph, spec)
+            nodes = self.get_nodes_from_intersection_spec(graph, raw_spec)
 
             if check_existence:
                 alert_non_existence(raw_spec, nodes)


Test patch:
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
@@ -214,7 +214,7 @@ def test__postgres__childrens_parents(self):
     def test__postgres__more_childrens_parents(self):
         self.run_sql_file("seed.sql")
         results = self.run_dbt(['run', '--models', '@users'])
-        # base_users, emails, users_rollup, users_rollup_dependency, but not users (ephemeral)
+        # users, emails_alt, users_rollup, users_rollup_dependency, but not base_users (ephemeral)
         self.assertEqual(len(results), 4)
 
         created_models = self.get_models_in_schema()
@@ -244,3 +244,48 @@ def test__snowflake__skip_intermediate(self):
             user_last_end <= dep_first_start,
             'dependency started before its transitive parent ({} > {})'.format(user_last_end, dep_first_start)
         )
+
+    @use_profile('postgres')
+    def test__postgres__concat(self):
+        self.run_sql_file("seed.sql")
+        results = self.run_dbt(['run', '--models', '@emails_alt', 'users_rollup'])
+        # users, emails_alt, users_rollup
+        self.assertEqual(len(results), 3)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users_rollup', created_models)
+        self.assertIn('users', created_models)
+        self.assertIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__concat_exclude(self):
+        self.run_sql_file("seed.sql")
+        results = self.run_dbt(['run', '--models', '@emails_alt', 'users_rollup', '--exclude', 'emails_alt'])
+        # users, users_rollup
+        self.assertEqual(len(results), 2)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertIn('users_rollup', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__concat_exclude_concat(self):
+        self.run_sql_file("seed.sql")
+        results = self.run_dbt(
+            ['run', '--models', '@emails_alt', 'users_rollup', '--exclude', 'emails_alt', 'users_rollup']
+        )
+        # users
+        self.assertEqual(len(results), 1)
+
+        created_models = self.get_models_in_schema()
+
+        self.assertIn('users', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
diff --git a/test/integration/007_graph_selection_tests/test_intersection_syntax.py b/test/integration/007_graph_selection_tests/test_intersection_syntax.py
new file mode 100644
--- /dev/null
+++ b/test/integration/007_graph_selection_tests/test_intersection_syntax.py
@@ -0,0 +1,223 @@
+from test.integration.base import DBTIntegrationTest, use_profile
+
+
+class TestGraphSelection(DBTIntegrationTest):
+
+    @property
+    def schema(self):
+        return "graph_selection_tests_007"
+
+    @property
+    def models(self):
+        return "models"
+
+    @use_profile('postgres')
+    def test__postgres__same_model_intersection(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(['run', '--models', 'users,users'])
+        # users
+        self.assertEqual(len(results), 1)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__tags_intersection(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(['run', '--models', 'tag:bi,tag:users'])
+        # users
+        self.assertEqual(len(results), 1)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__intersection_triple_descending(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(['run', '--models', '*,tag:bi,tag:users'])
+        # users
+        self.assertEqual(len(results), 1)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__intersection_triple_ascending(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(['run', '--models', 'tag:users,tag:bi,*'])
+        # users
+        self.assertEqual(len(results), 1)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__intersection_with_exclusion(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(['run', '--models', '+users_rollup_dependency,users+', '--exclude', 'users_rollup_dependency'])
+        # users, users_rollup
+        self.assertEqual(len(results), 2)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertIn('users_rollup', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__intersection_exclude_intersection(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(
+            ['run', '--models', 'tag:bi,@users', '--exclude',
+             'tag:bi,users_rollup+'])
+        # users
+        self.assertEqual(len(results), 1)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__intersection_exclude_intersection_lack(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(
+            ['run', '--models', 'tag:bi,@users', '--exclude',
+             '@emails,@emails_alt'])
+        # users, users_rollup
+        self.assertEqual(len(results), 2)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertIn('users_rollup', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+
+    @use_profile('postgres')
+    def test__postgres__intersection_exclude_triple_intersection(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(
+            ['run', '--models', 'tag:bi,@users', '--exclude',
+             '*,tag:bi,users_rollup'])
+        # users
+        self.assertEqual(len(results), 1)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__intersection_concat(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(
+            ['run', '--models', 'tag:bi,@users', 'emails_alt'])
+        # users, users_rollup, emails_alt
+        self.assertEqual(len(results), 3)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertIn('users_rollup', created_models)
+        self.assertIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__intersection_concat_intersection(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(
+            ['run', '--models', 'tag:bi,@users', '@emails_alt,emails_alt'])
+        # users, users_rollup, emails_alt
+        self.assertEqual(len(results), 3)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertIn('users_rollup', created_models)
+        self.assertIn('emails_alt', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__intersection_concat_exclude(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(
+            ['run', '--models', 'tag:bi,@users', 'emails_alt', '--exclude', 'users_rollup']
+        )
+        # users, emails_alt
+        self.assertEqual(len(results), 2)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertIn('emails_alt', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+    @use_profile('postgres')
+    def test__postgres__intersection_concat_exclude_concat(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(
+            ['run', '--models', 'tag:bi,@users', 'emails_alt,@users',
+             '--exclude', 'users_rollup_dependency', 'users_rollup'])
+        # users, emails_alt
+        self.assertEqual(len(results), 2)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertIn('emails_alt', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
+
+
+    @use_profile('postgres')
+    def test__postgres__intersection_concat_exclude_intersection_concat(self):
+        self.run_sql_file("seed.sql")
+
+        results = self.run_dbt(
+            ['run', '--models', 'tag:bi,@users', 'emails_alt,@users',
+             '--exclude', '@users,users_rollup_dependency', '@users,users_rollup'])
+        # users, emails_alt
+        self.assertEqual(len(results), 2)
+
+        created_models = self.get_models_in_schema()
+        self.assertIn('users', created_models)
+        self.assertIn('emails_alt', created_models)
+        self.assertNotIn('users_rollup', created_models)
+        self.assertNotIn('subdir', created_models)
+        self.assertNotIn('nested_users', created_models)
diff --git a/test/unit/test_graph_selection.py b/test/unit/test_graph_selection.py
--- a/test/unit/test_graph_selection.py
+++ b/test/unit/test_graph_selection.py
@@ -40,11 +40,11 @@ def create_graph(self):
 
     def add_tags(self, nodes):
         nodes['m.X.a'].tags = ['abc']
-        nodes['m.Y.b'].tags = ['abc']
-        nodes['m.X.c'].tags = ['abc']
+        nodes['m.Y.b'].tags = ['abc', 'bcef']
+        nodes['m.X.c'].tags = ['abc', 'bcef']
         nodes['m.Y.d'].tags = []
-        nodes['m.X.e'].tags = ['efg']
-        nodes['m.Y.f'].tags = ['efg']
+        nodes['m.X.e'].tags = ['efg', 'bcef']
+        nodes['m.Y.f'].tags = ['efg', 'bcef']
         nodes['m.X.g'].tags = ['efg']
 
     def run_specs_and_assert(self, graph, include, exclude, expected):
@@ -56,7 +56,6 @@ def run_specs_and_assert(self, graph, include, exclude, expected):
 
         self.assertEqual(selected, expected)
 
-
     def test__single_node_selection_in_package(self):
         self.run_specs_and_assert(
             self.package_graph,
@@ -126,6 +125,150 @@ def test__select_childrens_parents(self):
             set(['m.X.a', 'm.X.c', 'm.Y.f', 'm.X.g'])
         )
 
+    def test__select_concat(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:abc', 'tag:bcef'],
+            [],
+            set(['m.X.a', 'm.Y.b', 'm.X.c', 'm.X.e', 'm.Y.f'])
+        )
+
+    def test__select_concat_exclude(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:abc', 'tag:bcef'],
+            ['tag:efg'],
+            set(['m.X.a', 'm.Y.b', 'm.X.c'])
+        )
+
+    def test__select_concat_exclude_concat(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:abc', 'tag:bcef'],
+            ['tag:efg', 'a'],
+            set(['m.Y.b', 'm.X.c'])
+        )
+
+    def test__select_same_model_intersection(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['a,a'],
+            [],
+            set(['m.X.a'])
+        )
+
+    def test__select_layer_intersection(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['+c,c+'],
+            [],
+            set(['m.X.c'])
+        )
+
+    def test__select_intersection_lack(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['a,b'],
+            [],
+            set()
+        )
+
+    def test__select_tags_intersection(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:abc,tag:bcef'],
+            [],
+            set(['m.Y.b', 'm.X.c'])
+        )
+
+    def test__select_intersection_triple_descending(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['*,tag:abc,a'],
+            [],
+            set(['m.X.a'])
+        )
+
+    def test__select_intersection_triple_ascending(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['a,tag:abc,*'],
+            [],
+            set(['m.X.a'])
+        )
+
+    def test__select_intersection_with_exclusion(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:abc,tag:bcef'],
+            ['c'],
+            set(['m.Y.b'])
+        )
+
+    def test__select_intersection_exclude_intersection(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:bcef,tag:efg'],
+            ['tag:bcef,@b'],
+            set(['m.Y.f'])
+        )
+
+    def test__select_intersection_exclude_intersection_lack(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:bcef,tag:efg'],
+            ['tag:bcef,@a'],
+            set()
+        )
+
+    def test__select_intersection_exclude_triple_intersection(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['*,@a,+b'],
+            ['*,tag:abc,tag:bcef'],
+            set(['m.X.a'])
+        )
+
+    def test__select_concat_intersection(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:bcef,tag:efg', '*,tag:abc'],
+            [],
+            set(['m.X.a', 'm.Y.b', 'm.X.c', 'm.X.e', 'm.Y.f'])
+        )
+
+    def test__select_concat_intersection_exclude(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:bcef,tag:efg', '*,tag:abc'],
+            ['e'],
+            set(['m.X.a', 'm.Y.b', 'm.X.c', 'm.Y.f'])
+        )
+
+    def test__select_concat_intersection_exclude_concat(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:bcef,tag:efg', '*,tag:abc'],
+            ['e', 'f'],
+            set(['m.X.a', 'm.Y.b', 'm.X.c'])
+        )
+
+    def test__select_concat_intersection_exclude_intersection(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:bcef,tag:efg', '*,tag:abc'],
+            ['tag:abc,tag:bcef'],
+            set(['m.X.a', 'm.X.e', 'm.Y.f'])
+        )
+
+    def test__select_concat_intersection_exclude_intersection_concat(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['tag:bcef,tag:efg', '*,tag:abc'],
+            ['tag:abc,tag:bcef', 'tag:abc,a'],
+            set(['m.X.e', 'm.Y.f'])
+        )
+
     def parse_spec_and_assert(self, spec, parents, children, filter_type, filter_value, childrens_parents):
         parsed = graph_selector.SelectionCriteria(spec)
         self.assertEqual(parsed.select_parents, parents)