Repository: dbt-labs/dbt-core

Issue:
Feature: @model to build additional dependencies
## Feature

### Feature description
When using the `--models` flag in conjunction with `+` only results in the downstream/upstream dependencies being built (and not their individual dependencies).  It would be useful to have a `++modelName++` syntax to build that path on the tree including the dependencies of the dependent/parent models.

### Who will this benefit?
Anyone using selective builds in clean CI environments.

### Edit: Updated Feature Description (See below)

Add an `@` modifier (applicable only at the beginning of a selector) which will select:
 - all of the descendants of the specified resource
 - all of the ancestors of _those_ resources

Examples:
```
# Run everything downstream of a source, plus transitive parents of those downstream models
dbt run --model @source:snowplow

# Run a model, plus everything downstream, plus transitive parents of those downstream models
dbt run --model @some_model
```


Discussion:
thanks for the suggestion @swettk! I'm super into the idea, but open to alternatives for the syntax. Ideally, we'd pick something 1) sensible 2) easy to parse 3) conflict-free with most terminals.
This is currently prioritized for the Stephen Girard release, but our thinking needs to be developed a little further before we can tackle it.

1. Rather than `++`, let's use `@`. It's going to be less confusing/easier to implement than repurposing the `+` I think.
2. What does the `@` sign mean at the beginning/end of the selector?
 - At the end: Run the parents of the children of the selected model
 - At the beginning: I don't know that this is super useful, nor do I know exactly what it would indicate? I guess the opposite of the above is: "Run the children of the parents of the selected model". That might be consistent, but I am hard pressed to think of a good use case for that.

Either way, the `@` selector has a weird effect of kind of doing the _opposite_ of what you would think. If you do
```
dbt run --model my_model@
```

dbt will run:
1. my_model
2. all of the children of my_model
3. all of the parents of the children of my_model

Almost by definition, (3) will include the _parents_ of my_model, right? That's a little counter-intuitive, and it's not really what I would expect here!

This almost makes me think that this functionality shouldn't be implemented with a selector modifier like `@`, but instead with some other flag. Like `--transitive-closure` or something :). I think the use case is predominately for CI envs (as noted above) and I don't want to convolute the human-centric parts of model selection for something that will mainly be used by machines.

Keen to discuss, but let's answer these questions before doing any work on this issue
@drewbanin can we drop the before/after stuff, and just define `dbt run --model @my_model` as: "Run my_model and all its descendants, and every other model that is an input to those descendants"? In that world `my_model@` doesn't mean anything, and is invalid
Yeah, I'm into that. I think this is also going to be really helpful for sources, right? 
```
dbt run --model @source:snowplow
```

Run every model that depends on Snowplow source data, plus all of their inputs? Should be pretty useful!

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
@@ -9,9 +9,43 @@
 SELECTOR_PARENTS = '+'
 SELECTOR_CHILDREN = '+'
 SELECTOR_GLOB = '*'
+SELECTOR_CHILDREN_AND_ANCESTORS = '@'
 SELECTOR_DELIMITER = ':'
 
 
+class SelectionCriteria(object):
+    def __init__(self, node_spec):
+        self.raw = node_spec
+        self.select_children = False
+        self.select_parents = False
+        self.select_childrens_parents = False
+        self.selector_type = SELECTOR_FILTERS.FQN
+
+        if node_spec.startswith(SELECTOR_CHILDREN_AND_ANCESTORS):
+            self.select_childrens_parents = True
+            node_spec = node_spec[1:]
+
+        if node_spec.startswith(SELECTOR_PARENTS):
+            self.select_parents = True
+            node_spec = node_spec[1:]
+
+        if node_spec.endswith(SELECTOR_CHILDREN):
+            self.select_children = True
+            node_spec = node_spec[:-1]
+
+        if self.select_children and self.select_childrens_parents:
+            raise dbt.exceptions.RuntimeException(
+                'Invalid node spec {} - "@" prefix and "+" suffix are '
+                'incompatible'.format(self.raw)
+            )
+
+        if SELECTOR_DELIMITER in node_spec:
+            selector_parts = node_spec.split(SELECTOR_DELIMITER, 1)
+            self.selector_type, self.selector_value = selector_parts
+        else:
+            self.selector_value = node_spec
+
+
 class SELECTOR_FILTERS(object):
     FQN = 'fqn'
     TAG = 'tag'
@@ -27,46 +61,6 @@ def split_specs(node_specs):
     return specs
 
 
-def parse_spec(node_spec):
-    select_children = False
-    select_parents = False
-    index_start = 0
-    index_end = len(node_spec)
-
-    if node_spec.startswith(SELECTOR_PARENTS):
-        select_parents = True
-        index_start = 1
-
-    if node_spec.endswith(SELECTOR_CHILDREN):
-        select_children = True
-        index_end -= 1
-
-    node_selector = node_spec[index_start:index_end]
-
-    if SELECTOR_DELIMITER in node_selector:
-        selector_parts = node_selector.split(SELECTOR_DELIMITER, 1)
-        selector_type, selector_value = selector_parts
-
-        node_filter = {
-            "type": selector_type,
-            "value": selector_value
-        }
-
-    else:
-        node_filter = {
-            "type": SELECTOR_FILTERS.FQN,
-            "value": node_selector
-
-        }
-
-    return {
-        "select_parents": select_parents,
-        "select_children": select_children,
-        "filter": node_filter,
-        "raw": node_spec
-    }
-
-
 def get_package_names(graph):
     return set([node.split(".")[1] for node in graph.nodes()])
 
@@ -196,79 +190,84 @@ def get_nodes_by_source(self, graph, source_full_name):
             if target_table is None or target_table == real_node.name:
                 yield node
 
-    def get_nodes_from_spec(self, graph, spec):
-        select_parents = spec['select_parents']
-        select_children = spec['select_children']
+    def select_childrens_parents(self, graph, selected):
+        ancestors_for = self.select_children(graph, selected) | selected
+        return self.select_parents(graph, ancestors_for) | ancestors_for
+
+    def select_children(self, graph, selected):
+        descendants = set()
+        for node in selected:
+            descendants.update(nx.descendants(graph, node))
+        return descendants
+
+    def select_parents(self, graph, selected):
+        ancestors = set()
+        for node in selected:
+            ancestors.update(nx.ancestors(graph, node))
+        return ancestors
+
+    def collect_models(self, graph, selected, spec):
+        additional = set()
+        if spec.select_childrens_parents:
+            additional.update(self.select_childrens_parents(graph, selected))
+        if spec.select_parents:
+            additional.update(self.select_parents(graph, selected))
+        if spec.select_children:
+            additional.update(self.select_children(graph, selected))
+        return additional
+
+    def collect_tests(self, graph, model_nodes):
+        test_nodes = set()
+        for node in model_nodes:
+            # include tests that depend on this node. if we aren't running
+            # tests, they'll be filtered out later.
+            child_tests = [n for n in graph.successors(node)
+                           if self.manifest.nodes[n].resource_type ==
+                           NodeType.Test]
+            test_nodes.update(child_tests)
+        return test_nodes
 
+    def get_nodes_from_spec(self, graph, spec):
         filter_map = {
             SELECTOR_FILTERS.FQN: self.get_nodes_by_qualified_name,
             SELECTOR_FILTERS.TAG: self.get_nodes_by_tag,
             SELECTOR_FILTERS.SOURCE: self.get_nodes_by_source,
         }
 
-        node_filter = spec['filter']
-        filter_method = filter_map.get(node_filter['type'])
+        filter_method = filter_map.get(spec.selector_type)
 
         if filter_method is None:
             valid_selectors = ", ".join(filter_map.keys())
             logger.info("The '{}' selector specified in {} is invalid. Must "
                         "be one of [{}]".format(
-                            node_filter['type'],
-                            spec['raw'],
+                            spec.selector_type,
+                            spec.raw,
                             valid_selectors))
 
-            selected_nodes = set()
-
-        else:
-            selected_nodes = set(filter_method(graph, node_filter['value']))
-
-        additional_nodes = set()
-        test_nodes = set()
-
-        if select_parents:
-            for node in selected_nodes:
-                parent_nodes = nx.ancestors(graph, node)
-                additional_nodes.update(parent_nodes)
+            return set()
 
-        if select_children:
-            for node in selected_nodes:
-                child_nodes = nx.descendants(graph, node)
-                additional_nodes.update(child_nodes)
-
-        model_nodes = selected_nodes | additional_nodes
-
-        for node in model_nodes:
-            # include tests that depend on this node. if we aren't running
-            # tests, they'll be filtered out later.
-            child_tests = [n for n in graph.successors(node)
-                           if self.manifest.nodes[n].resource_type ==
-                           NodeType.Test]
-            test_nodes.update(child_tests)
+        collected = set(filter_method(graph, spec.selector_value))
+        collected.update(self.collect_models(graph, collected, spec))
+        collected.update(self.collect_tests(graph, collected))
 
         # filter out all actual source nodes now that we've calculated any
         # children specifiers
         source_nodes = set(uid for uid, _ in self.source_nodes(graph))
 
-        return (model_nodes | test_nodes).difference(source_nodes)
+        return collected.difference(source_nodes)
 
     def select_nodes(self, graph, raw_include_specs, raw_exclude_specs):
         selected_nodes = set()
 
-        split_include_specs = split_specs(raw_include_specs)
-        split_exclude_specs = split_specs(raw_exclude_specs)
-
-        include_specs = [parse_spec(spec) for spec in split_include_specs]
-        exclude_specs = [parse_spec(spec) for spec in split_exclude_specs]
-
-        for spec in include_specs:
+        for raw_spec in split_specs(raw_include_specs):
+            spec = SelectionCriteria(raw_spec)
             included_nodes = self.get_nodes_from_spec(graph, spec)
-            warn_if_useless_spec(spec, included_nodes)
-            selected_nodes = selected_nodes | included_nodes
+            selected_nodes.update(included_nodes)
 
-        for spec in exclude_specs:
+        for raw_spec in split_specs(raw_exclude_specs):
+            spec = SelectionCriteria(raw_spec)
             excluded_nodes = self.get_nodes_from_spec(graph, spec)
-            warn_if_useless_spec(spec, excluded_nodes)
-            selected_nodes = selected_nodes - excluded_nodes
+            selected_nodes.difference_update(excluded_nodes)
 
         return selected_nodes
 


Test patch:
diff --git a/test/integration/007_graph_selection_tests/models/emails_alt.sql b/test/integration/007_graph_selection_tests/models/emails_alt.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/007_graph_selection_tests/models/emails_alt.sql
@@ -0,0 +1 @@
+select distinct email from {{ ref('users') }}
diff --git a/test/integration/007_graph_selection_tests/models/users.sql b/test/integration/007_graph_selection_tests/models/users.sql
--- a/test/integration/007_graph_selection_tests/models/users.sql
+++ b/test/integration/007_graph_selection_tests/models/users.sql
@@ -2,7 +2,7 @@
 {{
     config(
         materialized = 'table',
-        tags='bi'
+        tags=['bi', 'users']
     )
 }}
 
diff --git a/test/integration/007_graph_selection_tests/models/users_rollup.sql b/test/integration/007_graph_selection_tests/models/users_rollup.sql
--- a/test/integration/007_graph_selection_tests/models/users_rollup.sql
+++ b/test/integration/007_graph_selection_tests/models/users_rollup.sql
@@ -2,7 +2,7 @@
 {{
     config(
         materialized = 'view',
-        tags = ['bi']
+        tags = 'bi'
     )
 }}
 
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
@@ -60,11 +60,12 @@ def test__postgres__tags_and_children(self):
         self.run_sql_file("test/integration/007_graph_selection_tests/seed.sql")
 
         results = self.run_dbt(['run', '--models', 'tag:base+'])
-        self.assertEqual(len(results), 2)
+        self.assertEqual(len(results), 3)
 
         created_models = self.get_models_in_schema()
         self.assertFalse('base_users' in created_models)
         self.assertFalse('emails' in created_models)
+        self.assertIn('emails_alt', created_models)
         self.assertTrue('users_rollup' in created_models)
         self.assertTrue('users' in created_models)
         self.assert_correct_schemas()
@@ -88,13 +89,14 @@ def test__postgres__specific_model_and_children(self):
         self.run_sql_file("test/integration/007_graph_selection_tests/seed.sql")
 
         results = self.run_dbt(['run', '--models', 'users+'])
-        self.assertEqual(len(results),  2)
+        self.assertEqual(len(results),  3)
 
         self.assertTablesEqual("seed", "users")
         self.assertTablesEqual("summary_expected", "users_rollup")
         created_models = self.get_models_in_schema()
-        self.assertFalse('base_users' in created_models)
-        self.assertFalse('emails' in created_models)
+        self.assertIn('emails_alt', created_models)
+        self.assertNotIn('base_users', created_models)
+        self.assertNotIn('emails', created_models)
         self.assert_correct_schemas()
 
     @attr(type='snowflake')
@@ -102,7 +104,7 @@ def test__snowflake__specific_model_and_children(self):
         self.run_sql_file("test/integration/007_graph_selection_tests/seed.sql")
 
         results = self.run_dbt(['run', '--models', 'users+'])
-        self.assertEqual(len(results),  2)
+        self.assertEqual(len(results),  3)
 
         self.assertManyTablesEqual(
             ["SEED", "USERS"],
@@ -187,3 +189,30 @@ def test__postgres__locally_qualified_name(self):
         self.assertIn('subdir', created_models)
         self.assertIn('nested_users', created_models)
         self.assert_correct_schemas()
+
+    @attr(type='postgres')
+    def test__postgres__childrens_parents(self):
+        self.run_sql_file("test/integration/007_graph_selection_tests/seed.sql")
+        results = self.run_dbt(['run', '--models', '@base_users'])
+        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)
+
+    @attr(type='postgres')
+    def test__postgres__more_childrens_parents(self):
+        self.run_sql_file("test/integration/007_graph_selection_tests/seed.sql")
+        results = self.run_dbt(['run', '--models', '@users'])
+        # base_users, emails, users_rollup, but not users (ephemeral)
+        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)
diff --git a/test/integration/007_graph_selection_tests/test_schema_test_graph_selection.py b/test/integration/007_graph_selection_tests/test_schema_test_graph_selection.py
--- a/test/integration/007_graph_selection_tests/test_schema_test_graph_selection.py
+++ b/test/integration/007_graph_selection_tests/test_schema_test_graph_selection.py
@@ -26,7 +26,7 @@ def run_schema_and_assert(self, include, exclude, expected_tests):
         self.run_sql_file("test/integration/007_graph_selection_tests/seed.sql")
         self.run_dbt(["deps"])
         results = self.run_dbt(['run', '--exclude', 'never_selected'])
-        self.assertEqual(len(results), 7)
+        self.assertEqual(len(results), 8)
 
         args = FakeArgs()
         args.models = include
diff --git a/test/integration/007_graph_selection_tests/test_tag_selection.py b/test/integration/007_graph_selection_tests/test_tag_selection.py
--- a/test/integration/007_graph_selection_tests/test_tag_selection.py
+++ b/test/integration/007_graph_selection_tests/test_tag_selection.py
@@ -61,3 +61,16 @@ def test__postgres__select_tag_in_model_with_project_Config(self):
         self.assertTrue('users' in models_run)
         self.assertTrue('users_rollup' in models_run)
 
+
+
+    # check that model configs aren't squashed by project configs
+    @use_profile('postgres')
+    def test__postgres__select_tag_in_model_with_project_Config(self):
+        self.run_sql_file("test/integration/007_graph_selection_tests/seed.sql")
+
+        results = self.run_dbt(['run', '--models', '@tag:users'])
+        self.assertEqual(len(results), 3)
+
+        models_run = set(r.node['name'] for r in results)
+        self.assertEqual({'users', 'users_rollup', 'emails_alt'}, models_run)
+
diff --git a/test/integration/042_sources_test/test_sources.py b/test/integration/042_sources_test/test_sources.py
--- a/test/integration/042_sources_test/test_sources.py
+++ b/test/integration/042_sources_test/test_sources.py
@@ -88,6 +88,17 @@ def test_source_only_def(self):
             ['expected_multi_source', 'multi_source_model'])
         self.assertTableDoesNotExist('nonsource_descendant')
 
+    @use_profile('postgres')
+    def test_source_childrens_parents(self):
+        results = self.run_dbt_with_vars([
+            'run', '--models', '@source:test_source'
+        ])
+        self.assertEqual(len(results), 2)
+        self.assertManyTablesEqual(
+            ['source', 'descendant_model'],
+            ['expected_multi_source', 'multi_source_model'],
+        )
+        self.assertTableDoesNotExist('nonsource_descendant')
 
 class TestMalformedSources(DBTIntegrationTest):
     @property
@@ -111,3 +122,4 @@ def test_malformed_schema_strict_will_break_run(self):
     def test_malformed_schema_strict_will_break_run(self):
         with self.assertRaises(CompilationException):
             self.run_dbt(strict=True)
+
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
@@ -3,14 +3,32 @@
 
 import os
 import string
+import dbt.exceptions
 import dbt.graph.selector as graph_selector
 
 import networkx as nx
 
+class BaseGraphSelectionTest(unittest.TestCase):
+    def create_graph(self):
+        raise NotImplementedError
 
-class GraphSelectionTest(unittest.TestCase):
+    def add_tags(self, nodes):
+        pass
 
     def setUp(self):
+        self.package_graph = self.create_graph()
+        nodes = {
+            node: mock.MagicMock(fqn=node.split('.')[1:], tags=[])
+            for node in self.package_graph
+        }
+        self.add_tags(nodes)
+        self.manifest = mock.MagicMock(nodes=nodes)
+        self.linker = mock.MagicMock(graph=self.package_graph)
+        self.selector = graph_selector.NodeSelector(self.linker, self.manifest)
+
+
+class GraphSelectionTest(BaseGraphSelectionTest):
+    def create_graph(self):
         integer_graph = nx.balanced_tree(2, 2, nx.DiGraph())
 
         package_mapping = {
@@ -19,12 +37,9 @@ def setUp(self):
         }
 
         # Edges: [(X.a, Y.b), (X.a, X.c), (Y.b, Y.d), (Y.b, X.e), (X.c, Y.f), (X.c, X.g)]
-        self.package_graph = nx.relabel_nodes(integer_graph, package_mapping)
-        nodes = {
-            node: mock.MagicMock(fqn=node.split('.')[1:], tags=[])
-            for node in self.package_graph
-        }
+        return nx.relabel_nodes(integer_graph, package_mapping)
 
+    def add_tags(self, nodes):
         nodes['m.X.a'].tags = ['abc']
         nodes['m.Y.b'].tags = ['abc']
         nodes['m.X.c'].tags = ['abc']
@@ -33,11 +48,6 @@ def setUp(self):
         nodes['m.Y.f'].tags = ['efg']
         nodes['m.X.g'].tags = ['efg']
 
-        self.manifest = mock.MagicMock(nodes=nodes)
-        self.linker = mock.MagicMock(graph=self.package_graph)
-        self.selector = graph_selector.NodeSelector(self.linker, self.manifest)
-
-
     def run_specs_and_assert(self, graph, include, exclude, expected):
         selected = self.selector.select_nodes(
             graph,
@@ -109,41 +119,59 @@ def test__select_children_except_tag(self):
             ['tag:efg'],
             set(['m.X.a','m.Y.b','m.X.c', 'm.Y.d']))
 
-    def parse_spec_and_assert(self, spec, parents, children, filter_type, filter_value):
-        parsed = graph_selector.parse_spec(spec)
-        self.assertEquals(
-            parsed,
-            {
-                "select_parents": parents,
-                "select_children": children,
-                "filter": {
-                    'type': filter_type,
-                    'value': filter_value
-                },
-                "raw": spec
-            }
+    def test__select_childrens_parents(self):
+        self.run_specs_and_assert(
+            self.package_graph,
+            ['@X.c'],
+            [],
+            set(['m.X.a', 'm.X.c', 'm.Y.f', 'm.X.g'])
         )
 
+    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)
+        self.assertEqual(parsed.select_children, children)
+        self.assertEqual(parsed.selector_type, filter_type)
+        self.assertEqual(parsed.selector_value, filter_value)
+        self.assertEqual(parsed.select_childrens_parents, childrens_parents)
+
+    def invalid_spec(self, spec):
+        with self.assertRaises(dbt.exceptions.RuntimeException):
+            graph_selector.SelectionCriteria(spec)
+
     def test__spec_parsing(self):
-        self.parse_spec_and_assert('a', False, False, 'fqn', 'a')
-        self.parse_spec_and_assert('+a', True, False, 'fqn', 'a')
-        self.parse_spec_and_assert('a+', False, True, 'fqn', 'a')
-        self.parse_spec_and_assert('+a+', True, True, 'fqn', 'a')
-
-        self.parse_spec_and_assert('a.b', False, False, 'fqn', 'a.b')
-        self.parse_spec_and_assert('+a.b', True, False, 'fqn', 'a.b')
-        self.parse_spec_and_assert('a.b+', False, True, 'fqn', 'a.b')
-        self.parse_spec_and_assert('+a.b+', True, True, 'fqn', 'a.b')
-
-        self.parse_spec_and_assert('a.b.*', False, False, 'fqn', 'a.b.*')
-        self.parse_spec_and_assert('+a.b.*', True, False, 'fqn', 'a.b.*')
-        self.parse_spec_and_assert('a.b.*+', False, True, 'fqn', 'a.b.*')
-        self.parse_spec_and_assert('+a.b.*+', True, True, 'fqn', 'a.b.*')
-
-        self.parse_spec_and_assert('tag:a', False, False, 'tag', 'a')
-        self.parse_spec_and_assert('+tag:a', True, False, 'tag', 'a')
-        self.parse_spec_and_assert('tag:a+', False, True, 'tag', 'a')
-        self.parse_spec_and_assert('+tag:a+', True, True, 'tag', 'a')
+        self.parse_spec_and_assert('a', False, False, 'fqn', 'a', False)
+        self.parse_spec_and_assert('+a', True, False, 'fqn', 'a', False)
+        self.parse_spec_and_assert('a+', False, True, 'fqn', 'a', False)
+        self.parse_spec_and_assert('+a+', True, True, 'fqn', 'a', False)
+        self.parse_spec_and_assert('@a', False, False, 'fqn', 'a', True)
+        self.invalid_spec('@a+')
+
+        self.parse_spec_and_assert('a.b', False, False, 'fqn', 'a.b', False)
+        self.parse_spec_and_assert('+a.b', True, False, 'fqn', 'a.b', False)
+        self.parse_spec_and_assert('a.b+', False, True, 'fqn', 'a.b', False)
+        self.parse_spec_and_assert('+a.b+', True, True, 'fqn', 'a.b', False)
+        self.parse_spec_and_assert('@a.b', False, False, 'fqn', 'a.b', True)
+        self.invalid_spec('@a.b+')
+
+        self.parse_spec_and_assert('a.b.*', False, False, 'fqn', 'a.b.*', False)
+        self.parse_spec_and_assert('+a.b.*', True, False, 'fqn', 'a.b.*', False)
+        self.parse_spec_and_assert('a.b.*+', False, True, 'fqn', 'a.b.*', False)
+        self.parse_spec_and_assert('+a.b.*+', True, True, 'fqn', 'a.b.*', False)
+        self.parse_spec_and_assert('@a.b.*', False, False, 'fqn', 'a.b.*', True)
+        self.invalid_spec('@a.b*+')
+
+        self.parse_spec_and_assert('tag:a', False, False, 'tag', 'a', False)
+        self.parse_spec_and_assert('+tag:a', True, False, 'tag', 'a', False)
+        self.parse_spec_and_assert('tag:a+', False, True, 'tag', 'a', False)
+        self.parse_spec_and_assert('+tag:a+', True, True, 'tag', 'a', False)
+        self.parse_spec_and_assert('@tag:a', False, False, 'tag', 'a', True)
+        self.invalid_spec('@tag:a+')
+
+        self.parse_spec_and_assert('source:a', False, False, 'source', 'a', False)
+        self.parse_spec_and_assert('source:a+', False, True, 'source', 'a', False)
+        self.parse_spec_and_assert('@source:a', False, False, 'source', 'a', True)
+        self.invalid_spec('@source:a+')
 
     def test__package_name_getter(self):
         found = graph_selector.get_package_names(self.package_graph)