Repository: pybel/pybel

Issue:
Re-implement "get_subgraph"
Move from PyBEL-Tools and implement tests

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/pybel/pybel/pull/338?src=pr&el=h1) Report
> Merging [#338](https://codecov.io/gh/pybel/pybel/pull/338?src=pr&el=desc) into [develop](https://codecov.io/gh/pybel/pybel/commit/6b0eb5dcb19400f3a64ac4830747bfe8dcbe8141?src=pr&el=desc) will **increase** coverage by `0.43%`.
> The diff coverage is `73.45%`.

[![Impacted file tree graph](https://codecov.io/gh/pybel/pybel/pull/338/graphs/tree.svg?width=650&token=J7joRTRygG&height=150&src=pr)](https://codecov.io/gh/pybel/pybel/pull/338?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           develop    #338      +/-   ##
==========================================
+ Coverage    85.66%   86.1%   +0.43%     
==========================================
  Files          132     139       +7     
  Lines         6209    6405     +196     
  Branches       902     925      +23     
==========================================
+ Hits          5319    5515     +196     
+ Misses         690     687       -3     
- Partials       200     203       +3
```


| [Impacted Files](https://codecov.io/gh/pybel/pybel/pull/338?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/pybel/dsl/namespaces.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2RzbC9uYW1lc3BhY2VzLnB5) | `100% <ø> (ø)` | :arrow_up: |
| [src/pybel/tokens.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3Rva2Vucy5weQ==) | `86.66% <ø> (ø)` | :arrow_up: |
| [src/pybel/struct/filters/edge\_filters.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9maWx0ZXJzL2VkZ2VfZmlsdGVycy5weQ==) | `100% <ø> (ø)` | :arrow_up: |
| [src/pybel/struct/utils.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC91dGlscy5weQ==) | `59.09% <0%> (+32%)` | :arrow_up: |
| [src/pybel/manager/query\_manager.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL21hbmFnZXIvcXVlcnlfbWFuYWdlci5weQ==) | `68.85% <0%> (ø)` | :arrow_up: |
| [src/pybel/struct/query/constants.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9xdWVyeS9jb25zdGFudHMucHk=) | `100% <100%> (ø)` | |
| [src/pybel/struct/mutation/induction/annotations.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9tdXRhdGlvbi9pbmR1Y3Rpb24vYW5ub3RhdGlvbnMucHk=) | `100% <100%> (ø)` | :arrow_up: |
| [src/pybel/struct/pipeline/pipeline.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9waXBlbGluZS9waXBlbGluZS5weQ==) | `87.37% <100%> (+9.28%)` | :arrow_up: |
| [src/pybel/struct/query/exc.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9xdWVyeS9leGMucHk=) | `100% <100%> (ø)` | |
| [src/pybel/struct/mutation/induction/paths.py](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9tdXRhdGlvbi9pbmR1Y3Rpb24vcGF0aHMucHk=) | `66.66% <100%> (-0.58%)` | :arrow_down: |
| ... and [21 more](https://codecov.io/gh/pybel/pybel/pull/338/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pybel/pybel/pull/338?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pybel/pybel/pull/338?src=pr&el=footer). Last update [6b0eb5d...a54b926](https://codecov.io/gh/pybel/pybel/pull/338?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/pybel/canonicalize.py b/src/pybel/canonicalize.py
index 1cccbea3..d207a23c 100644
--- a/src/pybel/canonicalize.py
+++ b/src/pybel/canonicalize.py
@@ -286,10 +286,10 @@ def _to_bel_lines_footer(graph):
         yield 'SET SupportingText = "{}"'.format(PYBEL_AUTOEVIDENCE)
 
         for u, v, data in unqualified_edges_to_serialize:
-            yield '{} {} {}'.format(graph.node_to_bel(u), data[RELATION], graph.node_to_bel(v))
+            yield '{} {} {}'.format(u.as_bel(), data[RELATION], v.as_bel())
 
         for node in isolated_nodes_to_serialize:
-            yield graph.node_to_bel(node)
+            yield node.as_bel()
 
         yield 'UNSET SupportingText'
         yield 'UNSET Citation'
diff --git a/src/pybel/dsl/namespaces.py b/src/pybel/dsl/namespaces.py
index c4d0cf57..3698eadc 100644
--- a/src/pybel/dsl/namespaces.py
+++ b/src/pybel/dsl/namespaces.py
@@ -11,10 +11,16 @@ __all__ = [
 
 
 def chebi(name=None, identifier=None):
-    """Build a ChEBI abundance node."""
+    """Build a ChEBI abundance node.
+
+    :rtype: Abundance
+    """
     return Abundance(namespace='CHEBI', name=name, identifier=identifier)
 
 
 def hgnc(name=None, identifier=None):
-    """Build an HGNC protein node."""
+    """Build an HGNC protein node.
+
+    :rtype: Protein
+    """
     return Protein(namespace='HGNC', name=name, identifier=identifier)
diff --git a/src/pybel/manager/models.py b/src/pybel/manager/models.py
index c49ef096..6f188542 100644
--- a/src/pybel/manager/models.py
+++ b/src/pybel/manager/models.py
@@ -270,7 +270,7 @@ class Network(Base):
     id = Column(Integer, primary_key=True)
 
     name = Column(String(255), nullable=False, index=True, doc='Name of the given Network (from the BEL file)')
-    version = Column(String(16), nullable=False, doc='Release version of the given Network (from the BEL file)')
+    version = Column(String(255), nullable=False, doc='Release version of the given Network (from the BEL file)')
 
     authors = Column(Text, nullable=True, doc='Authors of the underlying BEL file')
     contact = Column(String(255), nullable=True, doc='Contact email from the underlying BEL file')
@@ -608,10 +608,11 @@ class Author(Base):
 
     id = Column(Integer, primary_key=True)
     name = Column(String(255), nullable=False, unique=True, index=True)
-    sha512 = Column(String(255), nullable=False, index=True, unique=True, )
+    sha512 = Column(String(255), nullable=False, index=True, unique=True)
 
     @classmethod
     def from_name(cls, name):
+        """Create an author by name, automatically populating the hash."""
         return Author(name=name, sha512=cls.hash_name(name))
 
     @staticmethod
@@ -633,9 +634,20 @@ class Author(Base):
 
     @classmethod
     def has_name(cls, name):
-        """Build a filter for if an author has a name."""
+        """Build a filter for if an author has a name.
+
+        :type name: str
+        """
         return cls.sha512 == cls.hash_name(name)
 
+    @classmethod
+    def has_name_in(cls, names):
+        """Build a filter if the author has any of the given names"""
+        return cls.sha512.in_({
+            cls.hash_name(name)
+            for name in names
+        })
+
     def __str__(self):
         return self.name
 
diff --git a/src/pybel/manager/query_manager.py b/src/pybel/manager/query_manager.py
index 50730fe8..dc673f1c 100644
--- a/src/pybel/manager/query_manager.py
+++ b/src/pybel/manager/query_manager.py
@@ -211,7 +211,7 @@ class QueryManager(LookupManager):
             if isinstance(author, string_types):
                 query = query.filter(Author.name.like(author))
             elif isinstance(author, Iterable):
-                query = query.filter(Author.name.in_(set(author)))
+                query = query.filter(Author.has_name_in(set(author)))
             else:
                 raise TypeError
 
diff --git a/src/pybel/struct/filters/edge_filters.py b/src/pybel/struct/filters/edge_filters.py
index 1c58da6b..f34aff16 100644
--- a/src/pybel/struct/filters/edge_filters.py
+++ b/src/pybel/struct/filters/edge_filters.py
@@ -27,8 +27,8 @@ def invert_edge_predicate(edge_predicate):
     """Build an edge predicate that is the inverse of the given edge predicate.
 
     :param edge_predicate: An edge predicate
-    :type edge_predicate: (pybel.BELGraph, tuple, tuple, int) -> bool
-    :rtype: (pybel.BELGraph, tuple, tuple, int) -> bool
+    :type edge_predicate: (pybel.BELGraph, BaseEntity, BaseEntity, str) -> bool
+    :rtype: (pybel.BELGraph, BaseEntity, BaseEntity, str) -> bool
     """
 
     def _inverse_filter(graph, u, v, k):
@@ -41,9 +41,9 @@ def and_edge_predicates(edge_predicates=None):
     """Concatenate multiple edge predicates to a new predicate that requires all predicates to be met.
 
     :param edge_predicates: a list of predicates (graph, node, node, key, data) -> bool
-    :type edge_predicates: Optional[(pybel.BELGraph, tuple, tuple, int) -> bool or iter[(pybel.BELGraph, tuple, tuple, int) -> bool]]
+    :type edge_predicates: Optional[(pybel.BELGraph, BaseEntity, BaseEntity, str) -> bool or iter[(pybel.BELGraph, BaseEntity, BaseEntity, str) -> bool]]
     :return: A combine filter
-    :rtype: (pybel.BELGraph, tuple, tuple, int) -> bool
+    :rtype: (pybel.BELGraph, BaseEntity, BaseEntity, str) -> bool
     """
 
     # If no filters are given, then return the trivially permissive filter
@@ -64,9 +64,9 @@ def and_edge_predicates(edge_predicates=None):
         """Pass only for an edge that pass all enclosed predicates.
 
         :param BELGraph graph: A BEL Graph
-        :param tuple u: A BEL node
-        :param tuple v: A BEL node
-        :param int k: The edge key between the given nodes
+        :param BaseEntity u: A BEL node
+        :param BaseEntity v: A BEL node
+        :param str k: The edge key between the given nodes
         :return: If the edge passes all enclosed predicates
         :rtype: bool
         """
@@ -83,9 +83,9 @@ def filter_edges(graph, edge_predicates=None):
 
     :param BELGraph graph: A BEL graph
     :param edge_predicates: A predicate or list of predicates
-    :type edge_predicates: None or ((pybel.BELGraph, tuple, tuple, int) -> bool) or iter[(pybel.BELGraph, tuple, tuple, int) -> bool]
+    :type edge_predicates: None or ((pybel.BELGraph, BaseEntity, BaseEntity, str) -> bool) or iter[(pybel.BELGraph, BaseEntity, BaseEntity, str) -> bool]
     :return: An iterable of edges that pass all predicates
-    :rtype: iter[tuple,tuple,int]
+    :rtype: iter[BaseEntity, BaseEntity, str]
     """
 
     # If no predicates are given, return the standard edge iterator
@@ -104,7 +104,7 @@ def count_passed_edge_filter(graph, edge_predicates=None):
 
     :param pybel.BELGraph graph: A BEL graph
     :param edge_predicates: A predicate or list of predicates
-    :type edge_predicates: Optional[(pybel.BELGraph, tuple, tuple, int) -> bool or iter[(pybel.BELGraph, tuple, tuple, int) -> bool]]
+    :type edge_predicates: Optional[(pybel.BELGraph, BaseEntity, BaseEntity, str) -> bool or iter[(pybel.BELGraph, BaseEntity, BaseEntity, str) -> bool]]
     :return: The number of edges passing a given set of predicates
     :rtype: int
     """
diff --git a/src/pybel/struct/filters/edge_predicates.py b/src/pybel/struct/filters/edge_predicates.py
index 89871232..9976fff4 100644
--- a/src/pybel/struct/filters/edge_predicates.py
+++ b/src/pybel/struct/filters/edge_predicates.py
@@ -11,6 +11,7 @@ from ...constants import (
     CITATION_TYPE_PUBMED, DEGRADATION, DIRECT_CAUSAL_RELATIONS, EVIDENCE, OBJECT, POLAR_RELATIONS, RELATION, SUBJECT,
     TRANSLOCATION,
 )
+from ...dsl import BiologicalProcess, Pathology
 
 __all__ = [
     'edge_predicate',
@@ -26,6 +27,7 @@ __all__ = [
     'edge_has_degradation',
     'edge_has_translocation',
     'edge_has_annotation',
+    'has_pathology_causal',
 ]
 
 
@@ -203,3 +205,20 @@ def edge_has_annotation(data, key):
         return
 
     return annotations.get(key)
+
+
+def has_pathology_causal(graph, u, v, k):
+    """Check if the subject is a pathology and has a causal relationship with a non bioprocess/pathology.
+
+    :param pybel.BELGraph graph: A BEL Graph
+    :param BaseEntity u: A BEL node
+    :param BaseEntity v: A BEL node
+    :param str k: The edge key between the given nodes
+    :return: If the subject of this edge is a pathology and it participates in a causal reaction.
+    :rtype: bool
+    """
+    return (
+        isinstance(u, Pathology) and
+        is_causal_relation(graph, u, v, k) and
+        not isinstance(v, (Pathology, BiologicalProcess))
+    )
diff --git a/src/pybel/struct/graph.py b/src/pybel/struct/graph.py
index 2c3786bf..8f70cb60 100644
--- a/src/pybel/struct/graph.py
+++ b/src/pybel/struct/graph.py
@@ -16,9 +16,9 @@ from ..constants import (
     ANNOTATIONS, ASSOCIATION, CITATION, CITATION_REFERENCE, CITATION_TYPE, CITATION_TYPE_PUBMED, DECREASES, DESCRIPTION,
     DIRECTLY_DECREASES, DIRECTLY_INCREASES, EQUIVALENT_TO, EVIDENCE, GRAPH_ANNOTATION_LIST, GRAPH_ANNOTATION_PATTERN,
     GRAPH_ANNOTATION_URL, GRAPH_METADATA, GRAPH_NAMESPACE_PATTERN, GRAPH_NAMESPACE_URL, GRAPH_PYBEL_VERSION,
-    GRAPH_UNCACHED_NAMESPACES, HAS_COMPONENT, HAS_MEMBER, HAS_PRODUCT, HAS_REACTANT, HAS_VARIANT, IDENTIFIER,
-    INCREASES, IS_A, MEMBERS, METADATA_AUTHORS, METADATA_CONTACT, METADATA_COPYRIGHT, METADATA_DESCRIPTION,
-    METADATA_DISCLAIMER, METADATA_LICENSES, METADATA_NAME, METADATA_VERSION, NAME, NAMESPACE, OBJECT,
+    GRAPH_UNCACHED_NAMESPACES, HAS_COMPONENT, HAS_MEMBER, HAS_PRODUCT, HAS_REACTANT, HAS_VARIANT, INCREASES, IS_A,
+    MEMBERS, METADATA_AUTHORS, METADATA_CONTACT, METADATA_COPYRIGHT, METADATA_DESCRIPTION,
+    METADATA_DISCLAIMER, METADATA_LICENSES, METADATA_NAME, METADATA_VERSION, NAMESPACE, OBJECT,
     ORTHOLOGOUS, PART_OF, PRODUCTS, REACTANTS, RELATION, SUBJECT, TRANSCRIBED_TO, TRANSLATED_TO, VARIANTS,
 )
 from ..dsl import BaseEntity, activity
@@ -362,11 +362,6 @@ class BELGraph(nx.MultiDiGraph):
         :type attr: dict
         :return: str
         """
-        if not isinstance(u, BaseEntity):
-            raise TypeError('subject is not BaseEntity: {}'.format(u))
-        if not isinstance(v, BaseEntity):
-            raise TypeError('object is not BaseEntity: {}'.format(v))
-
         self.add_node_from_data(u)
         self.add_node_from_data(v)
 
@@ -587,17 +582,6 @@ class BELGraph(nx.MultiDiGraph):
                                        annotations=annotations, subject_modifier=subject_modifier,
                                        object_modifier=object_modifier, **attr)
 
-    def has_node(self, n):
-        """Check if the graph contains the given node tuple or BaseEntity.
-
-        :param n: A node
-        :rtype: bool
-        """
-        if not isinstance(n, BaseEntity):
-            raise TypeError('Not a base entity: {}'.format(n))
-
-        return super(BELGraph, self).has_node(n)
-
     def copy(self, as_view=False):  # TODO delete this so it uses base implementation
         """Copy this graph.
 
@@ -626,8 +610,7 @@ class BELGraph(nx.MultiDiGraph):
         :param BaseEntity node: A PyBEL node data dictionary
         :rtype: BaseEntity
         """
-        if not isinstance(node, BaseEntity):
-            raise TypeError('not BaseEntity: {}'.format(node))
+        assert isinstance(node, BaseEntity)
 
         if node in self:
             return node
@@ -729,15 +712,14 @@ class BELGraph(nx.MultiDiGraph):
 
     def _has_edge_attr(self, u, v, key, attr):
         """
-
-        :type u: BaseEntity or tuple
-        :type v: BaseEntity or tuple
+        :type u: BaseEntity
+        :type v: BaseEntity
         :type key: str
         :type attr: str
         :rtype: bool
         """
-        if not isinstance(u, BaseEntity):
-            raise TypeError
+        assert isinstance(u, BaseEntity)
+        assert isinstance(v, BaseEntity)
 
         return attr in self[u][v][key]
 
@@ -780,22 +762,17 @@ class BELGraph(nx.MultiDiGraph):
         return self._get_edge_attr(u, v, key, ANNOTATIONS)
 
     def _get_node_attr(self, node, attr):
-        if not isinstance(node, BaseEntity):
-            raise TypeError
-
+        assert isinstance(node, BaseEntity)
         return self.nodes[node].get(attr)
 
     def _has_node_attr(self, node, attr):
-        if not isinstance(node, BaseEntity):
-            raise TypeError
+        assert isinstance(node, BaseEntity)
         return attr in self.nodes[node]
 
     def _set_node_attr(self, node, attr, value):
-        if not isinstance(node, BaseEntity):
-            raise TypeError
+        assert isinstance(node, BaseEntity)
         self.nodes[node][attr] = value
 
-
     def get_node_description(self, node):
         """Get the description for a given node.
 
@@ -926,23 +903,18 @@ class BELGraph(nx.MultiDiGraph):
         :type n: tuple or BaseEntity
         :rtype: str
         """
-        if not isinstance(n, BaseEntity):
-            raise TypeError
-
         return n.as_bel()
 
     def edge_to_bel(self, u, v, data, sep=None):
         """Serialize a pair of nodes and related edge data as a BEL relation.
 
-        :type u: BaseEntity or tuple
-        :type v: BaseEntity or tuple
+        :type u: BaseEntity
+        :type v: BaseEntity
         :param dict data: A PyBEL edge data dictionary
         :param Optional[str] sep: The separator between the source, relation, and target. Defaults to ' '
         :rtype: str
         """
-        source = u if isinstance(u, BaseEntity) else self.node[u]
-        target = v if isinstance(v, BaseEntity) else self.node[v]
-        return edge_to_bel(source, target, data=data, sep=sep)
+        return edge_to_bel(u, v, data=data, sep=sep)
 
     def _has_no_equivalent_edge(self, u, v):
         return not any(
diff --git a/src/pybel/struct/mutation/induction/annotations.py b/src/pybel/struct/mutation/induction/annotations.py
index a7679a55..3c4d8706 100644
--- a/src/pybel/struct/mutation/induction/annotations.py
+++ b/src/pybel/struct/mutation/induction/annotations.py
@@ -39,17 +39,17 @@ def get_subgraph_by_annotations(graph, annotations, or_=None):
 
 
 @transformation
-def get_subgraph_by_annotation_value(graph, annotation, value):
+def get_subgraph_by_annotation_value(graph, annotation, values):
     """Induce a sub-graph over all edges whose annotations match the given key and value.
 
     :param pybel.BELGraph graph: A BEL graph
     :param str annotation: The annotation to group by
-    :param value: The value(s) for the annotation
-    :type value: str or iter[str]
+    :param values: The value(s) for the annotation
+    :type values: str or iter[str]
     :return: A subgraph of the original BEL graph
     :rtype: pybel.BELGraph
     """
-    if isinstance(value, string_types):
-        value = set(value)
+    if isinstance(values, string_types):
+        values = {values}
 
-    return get_subgraph_by_annotations(graph, {annotation: value})
+    return get_subgraph_by_annotations(graph, {annotation: values})
diff --git a/src/pybel/struct/mutation/induction/neighborhood.py b/src/pybel/struct/mutation/induction/neighborhood.py
index 08969c6a..6c6c6a70 100644
--- a/src/pybel/struct/mutation/induction/neighborhood.py
+++ b/src/pybel/struct/mutation/induction/neighborhood.py
@@ -27,18 +27,10 @@ def get_subgraph_by_neighborhood(graph, nodes):
         return
 
     rv = graph.fresh_copy()
-
-    rv.add_edges_from(
-        (
-            (u, v, k, d)
-            if k < 0 else
-            (u, v, d)
-        )
-        for u, v, k, d in itt.chain(
-            graph.in_edges_iter(nodes, keys=True, data=True),
-            graph.out_edges_iter(nodes, keys=True, data=True)
-        )
-    )
+    rv.add_edges_from(itt.chain(
+        graph.in_edges(nodes, keys=True, data=True),
+        graph.out_edges(nodes, keys=True, data=True),
+    ))
 
     update_node_helper(graph, rv)
     update_metadata(graph, rv)
diff --git a/src/pybel/struct/mutation/induction/paths.py b/src/pybel/struct/mutation/induction/paths.py
index 12bca138..74f6fb76 100644
--- a/src/pybel/struct/mutation/induction/paths.py
+++ b/src/pybel/struct/mutation/induction/paths.py
@@ -128,9 +128,4 @@ def get_random_path(graph):
     if tries == sentinel_tries:
         return [source]
 
-    shortest_path = nx.shortest_path(wg, source=source, target=target)
-
-    return [
-        graph.nodes[node]
-        for node in shortest_path
-    ]
+    return nx.shortest_path(wg, source=source, target=target)
diff --git a/src/pybel/struct/pipeline/pipeline.py b/src/pybel/struct/pipeline/pipeline.py
index 84621c84..0bac2af6 100644
--- a/src/pybel/struct/pipeline/pipeline.py
+++ b/src/pybel/struct/pipeline/pipeline.py
@@ -11,6 +11,7 @@ from .decorators import get_transformation, in_place_map, mapped, universe_map
 from .exc import MetaValueError, MissingPipelineFunctionError, MissingUniverseError
 from ..operations import node_intersection, union
 
+
 __all__ = [
     'Pipeline',
 ]
@@ -30,7 +31,7 @@ def _get_protocol_tuple(data):
     return data['function'], data.get('args', []), data.get('kwargs', {})
 
 
-class Pipeline(object):
+class Pipeline:
     """Builds and runs analytical pipelines on BEL graphs.
 
     Example usage:
@@ -45,13 +46,12 @@ class Pipeline(object):
     >>> result = example.run(graph)
     """
 
-    def __init__(self, protocol=None, universe=None):
+    def __init__(self, protocol=None):
         """
         :param iter[dict] protocol: An iterable of dictionaries describing how to transform a network
-        :param pybel.BELGraph universe: The entire set of known knowledge to draw from
         """
-        self.universe = universe
-        self.protocol = [] if protocol is None else protocol
+        self.universe = None
+        self.protocol = protocol or []
 
     def __len__(self):
         return len(self.protocol)
@@ -200,19 +200,7 @@ class Pipeline(object):
 
         return result
 
-    def _can_be_run_in_place(self):
-        """Checks if this pipeline can be run in place.
-
-        Requirements:
-
-        - All functions have the "in place" tag
-        - No splitting, unioning, or other exotic things happen.
-
-        :rtype: bool
-        """
-        raise NotImplementedError  # TODO implement
-
-    def run(self, graph, universe=None, in_place=True):
+    def run(self, graph, universe=None):
         """Run the contained protocol on a seed graph.
 
         :param pybel.BELGraph graph: The seed BEL graph
@@ -222,13 +210,10 @@ class Pipeline(object):
         :return: The new graph is returned if not applied in-place
         :rtype: pybel.BELGraph
         """
-        self.universe = graph.copy() if universe is None else universe
+        self.universe = universe or graph.copy()
+        return self._run_helper(graph.copy(), self.protocol)
 
-        result = graph if in_place else graph.copy()
-        result = self._run_helper(result, self.protocol)
-        return result
-
-    def __call__(self, graph, universe=None, in_place=True):
+    def __call__(self, graph, universe=None):
         """Call :meth:`Pipeline.run`.
 
         :param pybel.BELGraph graph: The seed BEL graph
@@ -247,7 +232,7 @@ class Pipeline(object):
         >>> graph = BELGraph() ...
         >>> new_graph = pipe(graph)
         """
-        return self.run(graph=graph, universe=universe, in_place=in_place)
+        return self.run(graph=graph, universe=universe)
 
     def _wrap_universe(self, func):
         """Take a function that needs a universe graph as the first argument and returns a wrapped one."""
@@ -275,19 +260,26 @@ class Pipeline(object):
 
         return wrapper
 
+    def to_json(self):
+        """Return this pipeline as a JSON list.
+
+        :rtype: list
+        """
+        return self.protocol
+
     def dumps(self, **kwargs):
-        """Give this pipeline as a JSON string.
+        """Dump this pipeline as a JSON string.
 
         :rtype: str
         """
-        return json.dumps(self.protocol, **kwargs)
+        return json.dumps(self.to_json(), **kwargs)
 
-    def dump(self, file):
+    def dump(self, file, **kwargs):
         """Dump this protocol to a file in JSON.
 
         :param file: A file or file-like to pass to :func:`json.dump`
         """
-        return json.dump(self.protocol, file)
+        return json.dump(self.to_json(), file, **kwargs)
 
     @staticmethod
     def load(file):
@@ -298,7 +290,7 @@ class Pipeline(object):
         :rtype: Pipeline
         :raises MissingPipelineFunctionError: If any functions are not registered
         """
-        return Pipeline(protocol=json.load(file))
+        return Pipeline(json.load(file))
 
     @staticmethod
     def loads(s):
@@ -309,7 +301,7 @@ class Pipeline(object):
         :rtype: Pipeline
         :raises MissingPipelineFunctionError: If any functions are not registered
         """
-        return Pipeline(protocol=json.loads(s))
+        return Pipeline(json.loads(s))
 
     def __str__(self):
         return json.dumps(self.protocol, indent=2)
diff --git a/src/pybel/struct/query/__init__.py b/src/pybel/struct/query/__init__.py
new file mode 100644
index 00000000..9bd40297
--- /dev/null
+++ b/src/pybel/struct/query/__init__.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+
+"""Query builder for PyBEL."""
+
+from .exc import *
+from .query import Query
+from .seeding import SEED_DATA, SEED_METHOD, Seeding
+from .selection import get_subgraph
diff --git a/src/pybel/struct/query/constants.py b/src/pybel/struct/query/constants.py
new file mode 100644
index 00000000..7c624097
--- /dev/null
+++ b/src/pybel/struct/query/constants.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+
+"""Constants for the query builder."""
+
+#: Induce a subgraph over the given nodes
+SEED_TYPE_INDUCTION = 'induction'
+#: Induce a subgraph over the given nodes and expand to their first neighbors
+SEED_TYPE_NEIGHBORS = 'neighbors'
+#: Induce a subgraph over the given nodes and expand to their second neighbors
+SEED_TYPE_DOUBLE_NEIGHBORS = 'dneighbors'
+#: Induce a subgraph over the nodes in all shortest paths between the given nodes
+SEED_TYPE_PATHS = 'shortest_paths'
+#: Induce a subgraph over the edges provided by the given authors and their neighboring nodes
+SEED_TYPE_AUTHOR = 'authors'
+#: Induce a subgraph over the edges provided by the given citations and their neighboring nodes
+SEED_TYPE_PUBMED = 'pubmed'
+#: Generate an upstream candidate mechanism
+SEED_TYPE_UPSTREAM = 'upstream'
+#: Generate a downstream candidate mechanism
+SEED_TYPE_DOWNSTREAM = 'downstream'
+#: Induce a subgraph over the edges matching the given annotations
+SEED_TYPE_ANNOTATION = 'annotation'
+#: Induce a subgraph over a random set of (hopefully) connected edges
+SEED_TYPE_SAMPLE = 'sample'
+
+#: A set of the allowed seed type strings, as defined above
+SEED_TYPES = {
+    SEED_TYPE_INDUCTION,
+    SEED_TYPE_NEIGHBORS,
+    SEED_TYPE_DOUBLE_NEIGHBORS,
+    SEED_TYPE_PATHS,
+    SEED_TYPE_UPSTREAM,
+    SEED_TYPE_DOWNSTREAM,
+    SEED_TYPE_PUBMED,
+    SEED_TYPE_AUTHOR,
+    SEED_TYPE_ANNOTATION,
+    SEED_TYPE_SAMPLE
+}
+
+#: Seed types that don't take node lists as their arguments
+NONNODE_SEED_TYPES = {
+    SEED_TYPE_ANNOTATION,
+    SEED_TYPE_AUTHOR,
+    SEED_TYPE_PUBMED,
+    SEED_TYPE_SAMPLE,
+}
diff --git a/src/pybel/struct/query/exc.py b/src/pybel/struct/query/exc.py
new file mode 100644
index 00000000..a3d9d75c
--- /dev/null
+++ b/src/pybel/struct/query/exc.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+
+"""Exceptions for the query builder."""
+
+__all__ = [
+    'QueryMissingNetworksError',
+    'NodeDegreeIterError',
+]
+
+
+class QueryMissingNetworksError(KeyError):
+    """Raised if a query is created from json but doesn't have a listing of network identifiers."""
+
+
+class NodeDegreeIterError(ValueError):
+    """Raised when failing to iterate over node degrees."""
diff --git a/src/pybel/struct/query/query.py b/src/pybel/struct/query/query.py
new file mode 100644
index 00000000..33731523
--- /dev/null
+++ b/src/pybel/struct/query/query.py
@@ -0,0 +1,214 @@
+# -*- coding: utf-8 -*-
+
+"""Query builder."""
+
+import json
+import logging
+from collections import Iterable
+
+from .exc import QueryMissingNetworksError
+from .seeding import Seeding
+from ...manager.models import Node
+from ...struct.pipeline import Pipeline
+
+__all__ = [
+    'Query',
+]
+
+log = logging.getLogger(__name__)
+
+
+class Query:
+    """Represents a query over a network store."""
+
+    def __init__(self, network_ids=None, seeding=None, pipeline=None):
+        """Build a query.
+
+        :param iter[int] network_ids: Database network identifiers identifiers
+        :type network_ids: None or int or iter[int]
+        :type seeding: Optional[Seeding]
+        :type pipeline: Optional[Pipeline]
+        """
+        if not network_ids:
+            self.network_ids = []
+        elif isinstance(network_ids, int):
+            self.network_ids = [network_ids]
+        elif isinstance(network_ids, Iterable):
+            network_ids = list(network_ids)
+
+            for network_id in network_ids:
+                if not isinstance(network_id, int):
+                    raise TypeError(network_ids)
+
+            self.network_ids = network_ids
+        else:
+            raise TypeError(network_ids)
+
+        if seeding is not None and not isinstance(seeding, Seeding):
+            raise TypeError('Not a Seeding: {}'.format(seeding))
+        self.seeding = seeding or Seeding()
+
+        if pipeline is not None and not isinstance(pipeline, Pipeline):
+            raise TypeError('Not a pipeline: {}'.format(pipeline))
+        self.pipeline = pipeline or Pipeline()
+
+    def append_network(self, network_id):
+        """Add a network to this query.
+
+        :param int network_id: The database identifier of the network
+        :returns: self for fluid API
+        :rtype: Query
+        """
+        self.network_ids.append(network_id)
+        return self
+
+    def append_seeding_induction(self, nodes):
+        """Add a seed induction method.
+
+        :param list[tuple or Node or BaseEntity] nodes: A list of PyBEL node tuples
+        :returns: seeding container for fluid API
+        :rtype: Seeding
+        """
+        return self.seeding.append_induction(nodes)
+
+    def append_seeding_neighbors(self, nodes):
+        """Add a seed by neighbors.
+
+        :param nodes: A list of PyBEL node tuples
+        :type nodes: BaseEntity or iter[BaseEntity]
+        """
+        return self.seeding.append_neighbors(nodes)
+
+    def append_seeding_annotation(self, annotation, values):
+        """Add a seed induction method for single annotation's values.
+
+        :param str annotation: The annotation to filter by
+        :param set[str] values: The values of the annotation to keep
+        """
+        return self.seeding.append_annotation(annotation, values)
+
+    def append_seeding_sample(self, **kwargs):
+        """Add seed induction methods.
+
+        Kwargs can have ``number_edges`` or ``number_seed_nodes``.
+        """
+        return self.seeding.append_sample(**kwargs)
+
+    def append_pipeline(self, name, *args, **kwargs):
+        """Add an entry to the pipeline. Defers to :meth:`pybel_tools.pipeline.Pipeline.append`.
+
+        :param name: The name of the function
+        :type name: str or types.FunctionType
+        :return: This pipeline for fluid query building
+        :rtype: Pipeline
+        """
+        return self.pipeline.append(name, *args, **kwargs)
+
+    def __call__(self, manager):
+        """Run this query and returns the resulting BEL graph with :meth:`Query.run`.
+
+        :param pybel.manager.Manager manager: A cache manager
+        :rtype: Optional[pybel.BELGraph]
+        """
+        return self.run(manager)
+
+    def run(self, manager):
+        """Run this query and returns the resulting BEL graph.
+
+        :param manager: A cache manager
+        :rtype: Optional[pybel.BELGraph]
+        """
+        universe = self._get_universe(manager)
+        graph = self.seeding.run(universe)
+        return self.pipeline.run(graph, universe=universe)
+
+    def _get_universe(self, manager):
+        if not self.network_ids:
+            raise QueryMissingNetworksError('can not run query without network identifiers')
+
+        log.debug('query universe consists of networks: %s', self.network_ids)
+
+        universe = manager.get_graph_by_ids(self.network_ids)
+        log.debug('query universe has %d nodes/%d edges', universe.number_of_nodes(), universe.number_of_edges())
+
+        return universe
+
+    def to_json(self):
+        """Return this query as a JSON object.
+
+        :rtype: dict
+        """
+        rv = {
+            'network_ids': self.network_ids,
+        }
+
+        if self.seeding:
+            rv['seeding'] = self.seeding.to_json()
+
+        if self.pipeline:
+            rv['pipeline'] = self.pipeline.to_json()
+
+        return rv
+
+    def dump(self, file, **kwargs):
+        """Dump this query to a file as JSON."""
+        json.dump(self.to_json(), file, **kwargs)
+
+    def dumps(self, **kwargs):
+        """Dump this query to a string as JSON
+
+        :rtype: str
+        """
+        return json.dumps(self.to_json(), **kwargs)
+
+    @staticmethod
+    def from_json(data):
+        """Load a query from a JSON dictionary.
+
+        :param dict data: A JSON dictionary
+        :rtype: Query
+        :raises: QueryMissingNetworksError
+        """
+        network_ids = data.get('network_ids')
+        if network_ids is None:
+            raise QueryMissingNetworksError('query JSON did not have key "network_ids"')
+
+        seeding_data = data.get('seeding')
+        seeding = (
+            Seeding(seeding_data)
+            if seeding_data is not None else
+            None
+        )
+
+        pipeline_data = data.get('pipeline')
+        pipeline = (
+            Pipeline(pipeline_data)
+            if pipeline_data is not None else
+            None
+        )
+
+        return Query(
+            network_ids=network_ids,
+            seeding=seeding,
+            pipeline=pipeline,
+        )
+
+    @staticmethod
+    def load(file):
+        """Load a query from a JSON file.
+
+        :param file: A file or file-like
+        :rtype: Query
+        :raises: QueryMissingNetworksError
+        """
+        return Query.from_json(json.load(file))
+
+    @staticmethod
+    def loads(s):
+        """Load a query from a JSON string
+
+        :param str s: A stringified JSON query
+        :rtype: Query
+        :raises: QueryMissingNetworksError
+        """
+        return Query.from_json(json.loads(s))
diff --git a/src/pybel/struct/query/seeding.py b/src/pybel/struct/query/seeding.py
new file mode 100644
index 00000000..ce26f50d
--- /dev/null
+++ b/src/pybel/struct/query/seeding.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+
+"""Query builder."""
+
+import json
+import logging
+import random
+
+from six.moves import UserList
+
+from .constants import (
+    SEED_TYPE_ANNOTATION, SEED_TYPE_INDUCTION, SEED_TYPE_NEIGHBORS, SEED_TYPE_SAMPLE,
+)
+from .selection import get_subgraph
+from ...dsl import BaseEntity
+from ...manager.models import Node
+from ...struct import union
+from ...tokens import parse_result_to_dsl
+
+log = logging.getLogger(__name__)
+
+SEED_METHOD = 'type'
+SEED_DATA = 'data'
+
+
+class Seeding(UserList):
+    """Represents a container of seeding methods to apply to a network."""
+
+    def append_induction(self, nodes):
+        """Add a seed induction method.
+
+        :param list[tuple or Node or BaseEntity] nodes: A list of PyBEL node tuples
+        :returns: self for fluid API
+        :rtype: Seeding
+        """
+        return self._append_seed(SEED_TYPE_INDUCTION, _handle_nodes(nodes))
+
+    def append_neighbors(self, nodes):
+        """Add a seed by neighbors.
+
+        :param nodes: A list of PyBEL node tuples
+        :type nodes: BaseEntity or iter[BaseEntity]
+        :returns: self for fluid API
+        :rtype: Seeding
+        """
+        return self._append_seed(SEED_TYPE_NEIGHBORS, _handle_nodes(nodes))
+
+    def append_annotation(self, annotation, values):
+        """Add a seed induction method for single annotation's values.
+
+        :param str annotation: The annotation to filter by
+        :param set[str] values: The values of the annotation to keep
+        :returns: self for fluid API
+        :rtype: Seeding
+        """
+        return self._append_seed(SEED_TYPE_ANNOTATION, {
+            'annotations': {
+                annotation: values
+            }
+        })
+
+    def append_sample(self, **kwargs):
+        """Add seed induction methods.
+
+        Kwargs can have ``number_edges`` or ``number_seed_nodes``.
+        :returns: self for fluid API
+        :rtype: Seeding
+        """
+        data = {
+            'seed': random.randint(0, 1000000)
+        }
+        data.update(kwargs)
+
+        return self._append_seed(SEED_TYPE_SAMPLE, data)
+
+    def _append_seed(self, seed_type, data):
+        """Add a seeding method.
+
+        :param str seed_type:
+        :param data:
+        :returns: self for fluid API
+        :rtype: Seeding
+        """
+        self.append({
+            SEED_METHOD: seed_type,
+            SEED_DATA: data,
+        })
+        return self
+
+    def run(self, graph):
+        """Seed the graph or return none if not possible.
+
+        :type graph: pybel.BELGraph
+        :rtype: Optional[pybel.BELGraph]
+        """
+        if not self:
+            log.debug('no seeding, returning graph: %s', graph)
+            return graph
+
+        subgraphs = []
+
+        for seed in self:
+            seed_method, seed_data = seed[SEED_METHOD], seed[SEED_DATA]
+
+            log.debug('seeding with %s: %s', seed_method, seed_data)
+            subgraph = get_subgraph(graph, seed_method=seed_method, seed_data=seed_data)
+
+            if subgraph is None:
+                log.debug('seed returned empty graph: %s', seed)
+                continue
+
+            subgraphs.append(subgraph)
+
+        if not subgraphs:
+            log.debug('no subgraphs returned')
+            return
+
+        return union(subgraphs)
+
+    def to_json(self):
+        """Serialize this seeding container to a JSON object.
+
+        :rtype: list
+        """
+        return list(self)
+
+    def dump(self, file, sort_keys=True, **kwargs):
+        """Dump this seeding container to a file as JSON."""
+        json.dump(self.to_json(), file, sort_keys=sort_keys, **kwargs)
+
+    def dumps(self, sort_keys=True, **kwargs):
+        """Dump this query to a string as JSON.
+
+        :rtype: str
+        """
+        return json.dumps(self.to_json(), sort_keys=sort_keys, **kwargs)
+
+    @staticmethod
+    def from_json(data):
+        """Build a seeding container from a JSON list.
+
+        :param dict data:
+        :rtype: Seeding
+        """
+        return Seeding(data)
+
+    @staticmethod
+    def load(file):
+        """Load a seeding container from a JSON file.
+
+        :rtype: Seeding
+        """
+        return Seeding.from_json(json.load(file))
+
+    @staticmethod
+    def loads(s):
+        """Load a seeding container from a JSON string.
+
+        :rtype: Seeding
+        """
+        return Seeding.from_json(json.loads(s))
+
+
+def _handle_nodes(nodes):
+    """Handle nodes that might be dictionaries.
+
+    :type nodes: BaseEntity or list[dict] or list[BaseEntity]
+    :rtype: list[BaseEntity]
+    """
+    if isinstance(nodes, BaseEntity):
+        return [nodes]
+
+    return [
+        (
+            parse_result_to_dsl(node)
+            if isinstance(node, dict) else
+            node
+        )
+        for node in nodes
+    ]
diff --git a/src/pybel/struct/query/selection.py b/src/pybel/struct/query/selection.py
new file mode 100644
index 00000000..fa5c4537
--- /dev/null
+++ b/src/pybel/struct/query/selection.py
@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+
+import logging
+
+from .constants import *
+from ..mutation import (
+    expand_nodes_neighborhoods, get_multi_causal_downstream, get_multi_causal_upstream,
+    get_random_subgraph, get_subgraph_by_all_shortest_paths, get_subgraph_by_annotations, get_subgraph_by_authors,
+    get_subgraph_by_induction, get_subgraph_by_neighborhood, get_subgraph_by_pubmed, get_subgraph_by_second_neighbors,
+)
+
+log = logging.getLogger(__name__)
+
+__all__ = [
+    'get_subgraph',
+]
+
+
+def get_subgraph(graph, seed_method=None, seed_data=None, expand_nodes=None, remove_nodes=None):
+    """Run a pipeline query on graph with multiple sub-graph filters and expanders.
+
+    Order of Operations:
+
+    1. Seeding by given function name and data
+    2. Add nodes
+    3. Remove nodes
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param str seed_method: The name of the get_subgraph_by_* function to use
+    :param seed_data: The argument to pass to the get_subgraph function
+    :param list[tuple] expand_nodes: Add the neighborhoods around all of these nodes
+    :param list[tuple] remove_nodes: Remove these nodes and all of their in/out edges
+    :rtype: Optional[pybel.BELGraph]
+    """
+    # Seed by the given function
+    if seed_method == SEED_TYPE_INDUCTION:
+        result = get_subgraph_by_induction(graph, seed_data)
+
+    elif seed_method == SEED_TYPE_PATHS:
+        result = get_subgraph_by_all_shortest_paths(graph, seed_data)
+
+    elif seed_method == SEED_TYPE_NEIGHBORS:
+        result = get_subgraph_by_neighborhood(graph, seed_data)
+
+    elif seed_method == SEED_TYPE_DOUBLE_NEIGHBORS:
+        result = get_subgraph_by_second_neighbors(graph, seed_data)
+
+    elif seed_method == SEED_TYPE_UPSTREAM:
+        result = get_multi_causal_upstream(graph, seed_data)
+
+    elif seed_method == SEED_TYPE_DOWNSTREAM:
+        result = get_multi_causal_downstream(graph, seed_data)
+
+    elif seed_method == SEED_TYPE_PUBMED:
+        result = get_subgraph_by_pubmed(graph, seed_data)
+
+    elif seed_method == SEED_TYPE_AUTHOR:
+        result = get_subgraph_by_authors(graph, seed_data)
+
+    elif seed_method == SEED_TYPE_ANNOTATION:
+        result = get_subgraph_by_annotations(graph, seed_data['annotations'], or_=seed_data.get('or'))
+
+    elif seed_method == SEED_TYPE_SAMPLE:
+        result = get_random_subgraph(
+            graph,
+            number_edges=seed_data.get('number_edges'),
+            seed=seed_data.get('seed')
+        )
+
+    elif not seed_method:  # Otherwise, don't seed a sub-graph
+        result = graph.copy()
+        log.debug('no seed function - using full network: %s', result.name)
+
+    else:
+        raise ValueError('Invalid seed method: {}'.format(seed_method))
+
+    if result is None:
+        log.debug('query returned no results')
+        return
+
+    log.debug('original graph has (%s nodes / %s edges)', result.number_of_nodes(), result.number_of_edges())
+
+    # Expand around the given nodes
+    if expand_nodes:
+        expand_nodes_neighborhoods(graph, result, expand_nodes)
+        log.debug('graph expanded to (%s nodes / %s edges)', result.number_of_nodes(), result.number_of_edges())
+
+    # Delete the given nodes
+    if remove_nodes:
+        for node in remove_nodes:
+            if node not in result:
+                log.debug('%s is not in graph %s', node, graph.name)
+                continue
+            result.remove_node(node)
+        log.debug('graph contracted to (%s nodes / %s edges)', result.number_of_nodes(), result.number_of_edges())
+
+    log.debug(
+        'Subgraph coming from %s (seed type) %s (data) contains %d nodes and %d edges',
+        seed_method,
+        seed_data,
+        result.number_of_nodes(),
+        result.number_of_edges()
+    )
+
+    return result
diff --git a/src/pybel/struct/utils.py b/src/pybel/struct/utils.py
index f00dfc2b..e15b1f51 100644
--- a/src/pybel/struct/utils.py
+++ b/src/pybel/struct/utils.py
@@ -54,41 +54,4 @@ def ensure_node_from_universe(source, target, node):
 
 
 def relabel_inplace(G, mapping):  # borrowed from NX
-    old_labels = set(mapping.keys())
-    new_labels = set(mapping.values())
-    if len(old_labels & new_labels) > 0:
-        # labels sets overlap
-        # can we topological sort and still do the relabeling?
-        D = nx.DiGraph(list(mapping.items()))
-        D.remove_edges_from(nx.selfloop_edges(D))
-        try:
-            nodes = reversed(list(nx.topological_sort(D)))
-        except nx.NetworkXUnfeasible:
-            raise nx.NetworkXUnfeasible('The node label sets are overlapping '
-                                        'and no ordering can resolve the '
-                                        'mapping. Use copy=True.')
-    else:
-        # non-overlapping label sets
-        nodes = old_labels
-
-    for old in nodes:
-        try:
-            new = mapping[old]
-        except KeyError:
-            continue
-        if new == old:
-            continue
-        try:
-            G.add_node(new)
-            G._node[new] = G.nodes[old]  # THIS WAS CHANGED
-        except KeyError:
-            raise KeyError("Node %s is not in the graph" % old)
-        new_edges = [(new, new if old == target else target, key, data)
-                     for (_, target, key, data)
-                     in G.edges(old, data=True, keys=True)]
-        new_edges += [(new if old == source else source, new, key, data)
-                      for (source, _, key, data)
-                      in G.in_edges(old, data=True, keys=True)]
-        G.remove_node(old)
-        G.add_edges_from(new_edges)
-    return G
+    return nx.relabel_nodes(G, mapping, copy=False)
diff --git a/src/pybel/tokens.py b/src/pybel/tokens.py
index d8ca404b..60838c61 100644
--- a/src/pybel/tokens.py
+++ b/src/pybel/tokens.py
@@ -20,9 +20,9 @@ __all__ = [
 
 
 def parse_result_to_dsl(tokens):
-    """Convert a ParseResult to a PyBEL DSL object
+    """Convert a ParseResult to a PyBEL DSL object.
 
-    :type tokens: pyparsing.ParseResults
+    :type tokens: dict or pyparsing.ParseResults
     :rtype: BaseEntity
     """
     if MODIFIER in tokens:


Test patch:
diff --git a/src/pybel/testing/mock_manager.py b/src/pybel/testing/mock_manager.py
new file mode 100644
index 00000000..188ac564
--- /dev/null
+++ b/src/pybel/testing/mock_manager.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+
+"""Mocks for PyBEL testing."""
+
+from ..manager.models import Network
+from ..struct import union
+
+
+class MockQueryManager:
+    """A mock manager."""
+
+    def __init__(self, graphs=None):
+        """Build a mock manager appropriate for testing the pipeline and query builders.
+
+        :param Optional[list[pybel.BELGraph]] graphs: A list of BEL graphs to index
+        """
+        self.graphs = []
+
+        #: A lookup for nodes from the node hash (string) to the node tuple
+        self.hash_to_node = {}
+
+        #: A lookup from network identifier to graph
+        self.id_graph = {}
+
+        if graphs is not None:
+            for graph in graphs:
+                self.insert_graph(graph)
+
+    def count_networks(self):
+        """Count networks in the manager.
+
+        :rtype: int
+        """
+        return len(self.graphs)
+
+    def insert_graph(self, graph):
+        """Insert a graph and ensure its nodes are cached.
+
+        :param pybel.BELGraph graph:
+        :rtype: Network
+        """
+        network_id = len(self.graphs)
+        self.graphs.append(graph)
+        self.id_graph[network_id] = graph
+
+        for node in graph:
+            self.hash_to_node[node.sha512] = node
+
+        return Network(id=network_id)
+
+    def get_graph_by_ids(self, network_ids):
+        """Get a graph from the union of multiple networks.
+
+        :param iter[int] network_ids: The identifiers of networks in the database
+        :rtype: pybel.BELGraph
+        """
+        network_ids = list(network_ids)
+
+        if len(network_ids) == 1:
+            return self.id_graph[network_ids[0]]
+
+        graphs = [
+            self.id_graph[graph_id]
+            for graph_id in network_ids
+        ]
+
+        return union(graphs)
+
+    def get_dsl_by_hash(self, sha512):
+        """Get a DSL by its hash.
+
+        :param str sha512:
+        :rtype: Optional[BaseEntity]
+        """
+        return self.hash_to_node.get(sha512)
diff --git a/tests/constants.py b/tests/constants.py
index b873ef66..770e4477 100644
--- a/tests/constants.py
+++ b/tests/constants.py
@@ -74,10 +74,10 @@ def assert_has_node(self, node, graph, **kwargs):
     self.assertIn(
         node,
         graph,
-        msg='{} not found in graph. Other nodes:\n{}'.format(graph.node_to_bel(node), '\n'.join(
-            graph.node_to_bel(node)
-            for node in graph
-        ))
+        msg='{} not found in graph. Other nodes:\n{}'.format(node.as_bel(), '\n'.join(
+            n.as_bel()
+            for n in graph
+        )),
     )
 
     if kwargs:
@@ -132,7 +132,7 @@ def assert_has_edge(self, u, v, graph, permissive=True, **kwargs):
     self.assertTrue(
         graph.has_edge(u, v),
         msg='Edge ({}, {}) not in graph. Other edges:\n{}'.format(u, v, '\n'.join(
-            '{} {} {}'.format(graph.node_to_bel(u), d[RELATION], graph.node_to_bel(v))
+            '{} {} {}'.format(u.as_bel(), d[RELATION], v.as_bel())
             for u, v, d in graph.edges(data=True)
         ))
     )
diff --git a/tests/test_struct/test_filters/test_edge_predicates.py b/tests/test_struct/test_filters/test_edge_predicates.py
new file mode 100644
index 00000000..fa76cd20
--- /dev/null
+++ b/tests/test_struct/test_filters/test_edge_predicates.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for edge predicates"""
+
+import unittest
+
+from pybel import BELGraph
+from pybel.dsl import pathology, protein
+from pybel.struct.filters.edge_predicates import has_pathology_causal
+from pybel.testing.utils import n
+
+
+class TestEdgePredicates(unittest.TestCase):
+    """Tests for edge predicates."""
+
+    def test_has_pathology(self):
+        """Test for checking edges that have a causal pathology."""
+        graph = BELGraph()
+
+        a, b, c = protein(n(), n()), pathology(n(), n()), pathology(n(), n())
+
+        key = graph.add_increases(a, b, n(), n())
+        self.assertFalse(has_pathology_causal(graph, a, b, key))
+
+        key = graph.add_increases(b, a, n(), n())
+        self.assertTrue(has_pathology_causal(graph, b, a, key))
+
+        key = graph.add_association(b, a, n(), n())
+        self.assertFalse(has_pathology_causal(graph, b, a, key))
+
+        key = graph.add_increases(a, c, n(), n())
+        self.assertFalse(has_pathology_causal(graph, a, c, key))
diff --git a/tests/test_struct/test_query/__init__.py b/tests/test_struct/test_query/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_struct/test_query/test_mocks.py b/tests/test_struct/test_query/test_mocks.py
new file mode 100644
index 00000000..3317546e
--- /dev/null
+++ b/tests/test_struct/test_query/test_mocks.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for the mocks for the query builder."""
+
+import unittest
+
+from pybel.examples import egf_graph
+from pybel.testing.mock_manager import MockQueryManager
+
+
+class TestMockManager(unittest.TestCase):
+    """Tests for the mock query manager."""
+
+    def test_make(self):
+        """Test instantiating the mock query manager."""
+        manager = MockQueryManager()
+        self.assertEqual(0, manager.count_networks())
+
+    def test_make_with_graph(self):
+        """Test counting networks in the mock query manager."""
+        manager = MockQueryManager(graphs=[egf_graph])
+        self.assertEqual(1, manager.count_networks())
+
+    def test_add_graph(self):
+        """Test adding a graph with insert_graph."""
+        manager = MockQueryManager()
+        graph = egf_graph.copy()
+        manager.insert_graph(graph)
+        self.assertEqual(1, manager.count_networks())
diff --git a/tests/test_struct/test_query/test_query.py b/tests/test_struct/test_query/test_query.py
new file mode 100644
index 00000000..4f320459
--- /dev/null
+++ b/tests/test_struct/test_query/test_query.py
@@ -0,0 +1,307 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for the query builder."""
+
+import logging
+import unittest
+
+from pybel import BELGraph, Pipeline
+from pybel.dsl import Protein
+from pybel.examples.egf_example import egf_graph, vcp
+from pybel.examples.homology_example import (
+    homology_graph, mouse_csf1_protein, mouse_csf1_rna, mouse_mapk1_protein, mouse_mapk1_rna,
+)
+from pybel.examples.sialic_acid_example import (cd33_phosphorylated, dap12, shp1, shp2, sialic_acid_graph, syk, trem2)
+from pybel.struct import expand_node_neighborhood, expand_nodes_neighborhoods, get_subgraph_by_annotation_value
+from pybel.struct.mutation import collapse_to_genes, enrich_protein_and_rna_origins
+from pybel.struct.query import Query, QueryMissingNetworksError, Seeding
+from pybel.testing.generate import generate_random_graph
+from pybel.testing.mock_manager import MockQueryManager
+from pybel.testing.utils import n
+
+log = logging.getLogger(__name__)
+
+
+def add(query, manager, graph):
+    network = manager.insert_graph(graph)
+    query.append_network(network.id)
+
+
+class TestSeedingConstructor(unittest.TestCase):
+
+    def test_none(self):
+        """Test construction of a seeding container."""
+        seeding = Seeding()
+        self.assertEqual(0, len(seeding))
+        self.assertEqual('[]', seeding.dumps())
+
+    def test_append_sample(self):
+        seeding = Seeding()
+        seeding.append_sample()
+        self.assertEqual(1, len(seeding))
+
+        s = seeding.dumps()
+        self.assertIsInstance(s, str)
+
+
+class TestQueryConstructor(unittest.TestCase):
+    """Test the construction of a Query."""
+
+    def test_network_ids_none(self):
+        query = Query()
+        self.assertIsInstance(query.network_ids, list)
+        self.assertIsInstance(query.seeding, Seeding)
+        self.assertIsInstance(query.pipeline, Pipeline)
+        self.assertEqual(0, len(query.network_ids))
+
+    def test_network_ids_single(self):
+        query = Query(network_ids=1)
+        self.assertIsInstance(query.network_ids, list)
+        self.assertEqual(1, len(query.network_ids))
+
+    def test_network_ids_multiple(self):
+        query = Query(network_ids=[1, 2, 3])
+        self.assertIsInstance(query.network_ids, list)
+        self.assertEqual(3, len(query.network_ids))
+
+    def test_network_ids_type_error(self):
+        with self.assertRaises(TypeError):
+            Query(network_ids='a')
+
+    def test_seeding(self):
+        query = Query(seeding=Seeding())
+        self.assertEqual(0, len(query.seeding))
+
+    def test_pipeline(self):
+        query = Query(pipeline=Pipeline())
+        self.assertEqual(0, len(query.pipeline))
+
+
+class QueryTestEgf(unittest.TestCase):
+    """Test querying the EGF subgraph"""
+
+    def setUp(self):
+        """Set up each test with a mock query manager."""
+        self.manager = MockQueryManager()
+        self.query = Query()
+
+    def add_query(self, graph):
+        add(self.query, self.manager, graph)
+        return self.query
+
+    def run_query(self):
+        return self.query.run(self.manager)
+
+    def test_fail_run_with_no_networks(self):
+        with self.assertRaises(QueryMissingNetworksError):
+            self.run_query()
+
+    def test_no_seeding_no_pipeline(self):
+        graph = egf_graph.copy()
+
+        self.add_query(graph)
+        result = self.run_query()
+
+        self.assertEqual(graph.number_of_nodes(), result.number_of_nodes())
+        self.assertEqual(graph.number_of_edges(), result.number_of_edges())
+
+    def test_seed_by_neighbor(self):
+        graph = BELGraph()
+        a, b, c, d = (Protein(namespace=n(), name=str(i)) for i in range(4))
+
+        graph.add_increases(a, b, n(), n())
+        graph.add_increases(b, c, n(), n())
+        graph.add_increases(c, d, n(), n())
+
+        self.add_query(graph).append_seeding_neighbors(b)
+        result = self.run_query()
+        self.assertIsInstance(result, BELGraph)
+        # test nodes
+        self.assertIn(a, result)
+        self.assertIn(b, result)
+        self.assertIn(c, result)
+        self.assertNotIn(d, result)
+        # test edges
+        self.assertIn(b, result[a])
+        self.assertIn(c, result[b])
+        self.assertNotIn(d, result[c])
+
+    def test_seed_by_neighbors(self):
+        graph = BELGraph()
+        a, b, c, d, e = (Protein(namespace=n(), name=str(i)) for i in range(5))
+
+        graph.add_increases(a, b, n(), n())
+        graph.add_increases(b, c, n(), n())
+        graph.add_increases(c, d, n(), n())
+        graph.add_increases(d, e, n(), n())
+
+        self.add_query(graph).append_seeding_neighbors([b, c])
+
+        result = self.run_query()
+        self.assertIsInstance(result, BELGraph)
+        # test nodes
+        self.assertIn(a, result)
+        self.assertIn(b, result)
+        self.assertIn(c, result)
+        self.assertIn(d, result)
+        self.assertNotIn(e, result)
+        # test edges
+        self.assertIn(b, result[a])
+        self.assertIn(c, result[b])
+        self.assertIn(d, result[c])
+        self.assertNotIn(e, result[d])
+
+    def test_random_sample(self):  # TODO this will fail randomly some times lol
+        graph = generate_random_graph(50, 1000)
+
+        query = self.add_query(graph)
+        query.append_seeding_sample(number_edges=10)
+        query.append_seeding_sample(number_edges=10)
+
+        result = self.run_query()
+
+        self.assertEqual(20, result.number_of_edges())
+
+
+class QueryTest(unittest.TestCase):
+    """Test the query"""
+
+    def setUp(self):
+        """Setup each test with an empty mock query manager."""
+        self.manager = MockQueryManager()
+
+    def test_pipeline(self):
+        graph = egf_graph.copy()
+        enrich_protein_and_rna_origins(graph)
+
+        self.assertEqual(
+            32,  # 10 protein nodes already there + complex + bp +  2*10 (genes and rnas)
+            graph.number_of_nodes()
+        )
+
+        # 6 already there + 5 complex hasComponent edges + new 2*10 edges
+        self.assertEqual(31, graph.number_of_edges())
+
+        network = self.manager.insert_graph(graph)
+
+        pipeline = Pipeline()
+        pipeline.append(collapse_to_genes)
+
+        query = Query(
+            network_ids=[network.id],
+            pipeline=pipeline
+        )
+        result_graph = query.run(self.manager)
+
+        self.assertEqual(12, result_graph.number_of_nodes())  # same number of nodes than there were
+        self.assertEqual(11, result_graph.number_of_edges())  # same number of edges than there were
+
+    def test_pipeline_2(self):
+        graph = egf_graph.copy()
+
+        network = self.manager.insert_graph(graph)
+        network_id = network.id
+
+        query = Query(network_ids=[network_id])
+        query.append_seeding_neighbors(vcp)
+        query.append_pipeline(get_subgraph_by_annotation_value, 'Species', '9606')
+
+        result = query.run(self.manager)
+        self.assertIsNotNone(result, msg='Query returned none')
+
+        self.assertEqual(3, result.number_of_nodes())
+
+    def test_query_multiple_networks(self):
+        sialic_acid_graph_id = self.manager.insert_graph(sialic_acid_graph.copy()).id
+        egf_graph_id = self.manager.insert_graph(egf_graph.copy()).id
+
+        query = Query()
+        query.append_network(sialic_acid_graph_id)
+        query.append_network(egf_graph_id)
+        query.append_seeding_neighbors([syk])
+        query.append_pipeline(enrich_protein_and_rna_origins)
+
+        result = query.run(self.manager)
+        self.assertIsNotNone(result, msg='Query returned none')
+
+        self.assertIn(shp1, result)
+        self.assertIn(shp2, result)
+        self.assertIn(trem2, result)
+        self.assertIn(dap12, result)
+
+        self.assertEqual(15, result.number_of_nodes())
+        self.assertEqual(14, result.number_of_edges())
+
+    def test_get_subgraph_by_annotation_value(self):
+        graph = homology_graph.copy()
+
+        result = get_subgraph_by_annotation_value(graph, 'Species', '10090')
+
+        self.assertIsNotNone(result, msg='Query returned none')
+        self.assertIsInstance(result, BELGraph)
+        self.assertLess(0, result.number_of_nodes())
+
+        self.assertIn(mouse_mapk1_protein, result, msg='nodes:\n{}'.format(list(map(repr, graph))))
+        self.assertIn(mouse_csf1_protein, result)
+
+        self.assertEqual(2, result.number_of_nodes())
+        self.assertEqual(1, result.number_of_edges())
+
+    def test_seeding_1(self):
+        test_network_1 = self.manager.insert_graph(homology_graph.copy())
+
+        query = Query(network_ids=[test_network_1.id])
+        query.append_seeding_neighbors([mouse_csf1_rna, mouse_mapk1_rna])
+
+        result = query.run(self.manager)
+        self.assertIsNotNone(result, msg='Query returned none')
+        self.assertIsInstance(result, BELGraph)
+
+        self.assertIn(mouse_mapk1_rna, result)
+        self.assertIn(mouse_csf1_rna, result)
+        self.assertIn(mouse_mapk1_protein, result)
+        self.assertIn(mouse_csf1_protein, result)
+
+        self.assertEqual(6, result.number_of_nodes())
+        self.assertEqual(4, result.number_of_edges())
+
+    def test_seeding_with_pipeline(self):
+        test_network_1 = self.manager.insert_graph(sialic_acid_graph.copy())
+
+        query = Query(network_ids=[test_network_1.id])
+        query.append_seeding_neighbors([trem2, dap12, shp2])
+        query.append_pipeline(expand_nodes_neighborhoods, [trem2, dap12, shp2])
+        result = query.run(self.manager)
+        self.assertIsNotNone(result, msg='Query returned none')
+        self.assertIsInstance(result, BELGraph)
+
+        self.assertIn(trem2, result)
+        self.assertIn(dap12, result)
+        self.assertIn(shp2, result)
+        self.assertIn(syk, result)
+        self.assertIn(cd33_phosphorylated, result)
+
+        self.assertEqual(5, result.number_of_nodes())
+        self.assertEqual(4, result.number_of_edges())
+
+    def test_query_multiple_networks_with_api(self):
+        test_network_1 = self.manager.insert_graph(homology_graph.copy())
+
+        pipeline = Pipeline()
+        pipeline.append(expand_node_neighborhood, mouse_mapk1_protein)
+
+        query = Query(
+            network_ids=[test_network_1.id],
+            pipeline=pipeline
+        )
+        query.append_seeding_annotation('Species', {'10090'})
+
+        result = query.run(self.manager)
+
+        self.assertIsNotNone(result, msg='Query returned none')
+
+        self.assertEqual(3, result.number_of_nodes())
+        self.assertIn(mouse_mapk1_protein, result)
+        self.assertIn(mouse_csf1_protein, result)
+
+        self.assertEqual(2, result.number_of_edges())
diff --git a/tests/test_struct/test_query/test_seeding.py b/tests/test_struct/test_query/test_seeding.py
new file mode 100644
index 00000000..324cbaa0
--- /dev/null
+++ b/tests/test_struct/test_query/test_seeding.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for the query builder."""
+
+import logging
+import unittest
+
+from pybel import BELGraph
+from pybel.dsl import Protein
+from pybel.examples.egf_example import egf_graph
+from pybel.struct.query import Seeding
+from pybel.testing.generate import generate_random_graph
+from pybel.testing.utils import n
+
+log = logging.getLogger(__name__)
+
+
+class TestSeedingConstructor(unittest.TestCase):
+
+    def test_none(self):
+        seeding = Seeding()
+        self.assertEqual(0, len(seeding))
+        self.assertEqual('[]', seeding.dumps())
+
+    def test_append_sample(self):
+        seeding = Seeding()
+        seeding.append_sample()
+        self.assertEqual(1, len(seeding))
+
+        s = seeding.dumps()
+        self.assertIsInstance(s, str)
+
+    def test_no_seeding(self):
+        graph = egf_graph.copy()
+
+        seeding = Seeding()
+        result = seeding.run(graph)
+
+        self.assertEqual(graph.number_of_nodes(), result.number_of_nodes())
+        self.assertEqual(graph.number_of_edges(), result.number_of_edges())
+
+    def test_seed_by_neighbor(self):
+        graph = BELGraph()
+        a, b, c, d = (Protein(namespace=n(), name=str(i)) for i in range(4))
+        graph.add_increases(a, b, n(), n())
+        graph.add_increases(b, c, n(), n())
+        graph.add_increases(c, d, n(), n())
+
+        seeding = Seeding()
+        seeding.append_neighbors(b)
+        result = seeding.run(graph)
+
+        self.assertIsInstance(result, BELGraph)
+        # test nodes
+        self.assertIn(a, result)
+        self.assertIn(b, result)
+        self.assertIn(c, result)
+        self.assertNotIn(d, result)
+        # test edges
+        self.assertIn(b, result[a])
+        self.assertIn(c, result[b])
+        self.assertNotIn(d, result[c])
+
+    def test_seed_by_neighbors(self):
+        graph = BELGraph()
+        a, b, c, d, e = (Protein(namespace=n(), name=str(i)) for i in range(5))
+        graph.add_increases(a, b, n(), n())
+        graph.add_increases(b, c, n(), n())
+        graph.add_increases(c, d, n(), n())
+        graph.add_increases(d, e, n(), n())
+
+        seeding = Seeding()
+        seeding.append_neighbors([b, c])
+        result = seeding.run(graph)
+
+        self.assertIsInstance(result, BELGraph)
+        # test nodes
+        self.assertIn(a, result)
+        self.assertIn(b, result)
+        self.assertIn(c, result)
+        self.assertIn(d, result)
+        self.assertNotIn(e, result)
+        # test edges
+        self.assertIn(b, result[a])
+        self.assertIn(c, result[b])
+        self.assertIn(d, result[c])
+        self.assertNotIn(e, result[d])
+
+    def test_random_sample(self):
+        graph = generate_random_graph(50, 1000)
+
+        seeding = Seeding()
+        seeding.append_sample(number_edges=10)
+        seeding.append_sample(number_edges=10)
+        result = seeding.run(graph)
+
+        # TODO this will fail randomly some times lol, so make allowed to be sort of wrong
+        self.assertIn(result.number_of_edges(), {18, 19, 20})
diff --git a/tests/test_struct/test_struct_pipeline.py b/tests/test_struct/test_query/test_struct_pipeline.py
similarity index 95%
rename from tests/test_struct/test_struct_pipeline.py
rename to tests/test_struct/test_query/test_struct_pipeline.py
index b5af0aed..29550544 100644
--- a/tests/test_struct/test_struct_pipeline.py
+++ b/tests/test_struct/test_query/test_struct_pipeline.py
@@ -114,7 +114,7 @@ class TestPipeline(TestEgfExample):
         pipeline = Pipeline.from_functions([
             'infer_central_dogma',
         ])
-        result = pipeline(self.graph, in_place=False)
+        result = pipeline(self.graph)
 
         self.assertEqual(32, result.number_of_nodes())
 
@@ -127,7 +127,7 @@ class TestPipeline(TestEgfExample):
         pipeline = Pipeline.from_functions([
             enrich_protein_and_rna_origins,
         ])
-        result = pipeline(self.graph, in_place=False)
+        result = pipeline(self.graph)
 
         self.assertEqual(32, result.number_of_nodes())
 
@@ -144,7 +144,7 @@ class TestDeprecation(unittest.TestCase):
 
         @transformation
         def test_function_1():
-            pass
+            """Test doing nothing."""
 
         self.assertNotIn('test_function_1', deprecated)
         self.assertIn('test_function_1', mapped)
@@ -155,7 +155,7 @@ class TestDeprecation(unittest.TestCase):
             @register_deprecated('test_function_1')
             @transformation
             def test_function_1_new():
-                pass
+                """Test bad uage of register_deprecated."""
 
         self.assertNotIn('test_function_1', deprecated)
 
@@ -165,7 +165,7 @@ class TestDeprecation(unittest.TestCase):
         @register_deprecated('test_function_2_old')
         @transformation
         def test_function_2():
-            pass
+            """Test usage of register_deprecated."""
 
         self.assertNotIn('test_function_2', deprecated)
         self.assertIn('test_function_2', mapped)
@@ -184,7 +184,7 @@ class TestDeprecation(unittest.TestCase):
         with self.assertRaises(MissingPipelineFunctionError):
             @register_deprecated('test_function_3_old')
             def test_function_3():
-                pass
+                """Test bad usage of register_deprecated that throws a MissingPipelineFunctionError."""
 
         self.assertNotIn('test_function_3', mapped)
         self.assertNotIn('test_function_3', universe_map)
diff --git a/tests/test_struct/test_transformations/test_induction.py b/tests/test_struct/test_transformations/test_induction.py
index 8a331111..7e8ff5c0 100644
--- a/tests/test_struct/test_transformations/test_induction.py
+++ b/tests/test_struct/test_transformations/test_induction.py
@@ -7,8 +7,7 @@ import unittest
 
 from pybel import BELGraph
 from pybel.constants import (
-    ASSOCIATION, CITATION_AUTHORS, CITATION_REFERENCE, CITATION_TYPE, CITATION_TYPE_PUBMED,
-    DECREASES, FUNCTION, INCREASES, PROTEIN,
+    ASSOCIATION, CITATION_AUTHORS, CITATION_REFERENCE, CITATION_TYPE, CITATION_TYPE_PUBMED, DECREASES, INCREASES,
 )
 from pybel.dsl import BaseEntity, gene, protein, rna
 from pybel.struct.mutation.expansion import expand_upstream_causal