Repository: pybel/pybel

Issue:
Implement annotation patterns in database

Discussion:

Patch:
diff --git a/.flake8 b/.flake8
index 0c6633f6..94398067 100644
--- a/.flake8
+++ b/.flake8
@@ -23,6 +23,8 @@ ignore =
     S311
     # Complains about pickles
     S301, S403
+    # Redefinition in CLI
+    F811
 exclude =
     .tox,
     .git,
diff --git a/src/pybel/canonicalize.py b/src/pybel/canonicalize.py
index f63d5ef0..d830dd97 100644
--- a/src/pybel/canonicalize.py
+++ b/src/pybel/canonicalize.py
@@ -93,7 +93,7 @@ def _decanonicalize_edge_node(
     edge_data: EdgeData,
     node_position: str,
     *,
-    use_identifiers: bool = False
+    use_identifiers: bool = True
 ) -> str:
     """Canonicalize a node with its modifiers stored in the given edge to a BEL string.
 
@@ -164,7 +164,7 @@ def edge_to_tuple(
     u: BaseEntity,
     v: BaseEntity,
     data: EdgeData,
-    use_identifiers: bool = False,
+    use_identifiers: bool = True,
 ) -> Tuple[str, str, str]:
     """Take two nodes and gives back a BEL string representing the statement.
 
@@ -183,7 +183,7 @@ def edge_to_bel(
     v: BaseEntity,
     data: EdgeData,
     sep: Optional[str] = None,
-    use_identifiers: bool = False,
+    use_identifiers: bool = True,
 ) -> str:
     """Take two nodes and gives back a BEL string representing the statement.
 
diff --git a/src/pybel/constants.py b/src/pybel/constants.py
index 6698ac56..154e5c3f 100644
--- a/src/pybel/constants.py
+++ b/src/pybel/constants.py
@@ -72,7 +72,7 @@ CITATION_DATE = 'date'
 #: Represents the key for the citation authors in a citation dictionary
 CITATION_AUTHORS = 'authors'
 #: Represents the key for the citation comment in a citation dictionary
-CITATION_JOURNAL = 'db_name'
+CITATION_JOURNAL = 'journal'
 #: Represents the key for the optional PyBEL citation volume entry in a citation dictionary
 CITATION_VOLUME = 'volume'
 #: Represents the key for the optional PyBEL citation issue entry in a citation dictionary
diff --git a/src/pybel/dsl/node_classes.py b/src/pybel/dsl/node_classes.py
index 4d3c0407..80d84d7b 100644
--- a/src/pybel/dsl/node_classes.py
+++ b/src/pybel/dsl/node_classes.py
@@ -52,6 +52,7 @@ __all__ = [
 
     # Variants
     'Variant',
+    'EntityVariant',
     'ProteinModification',
     'GeneModification',
     'Hgvs',
diff --git a/src/pybel/examples/egf_example.py b/src/pybel/examples/egf_example.py
index 387125ab..5a17cad4 100644
--- a/src/pybel/examples/egf_example.py
+++ b/src/pybel/examples/egf_example.py
@@ -58,9 +58,9 @@ egf_graph = BELGraph(
 )
 
 egf_graph.namespace_url.update({
-    'HGNC': HGNC_URL,
-    'CHEBI': CHEBI_URL,
-    'GO': GO_URL,
+    'hgnc': HGNC_URL,
+    'chebi': CHEBI_URL,
+    'go': GO_URL,
 })
 
 egf_graph.annotation_url.update({
@@ -71,21 +71,21 @@ egf_graph.annotation_pattern.update({
     'Species': SPECIES_PATTERN,
 })
 
-ar = Protein(name='AR', namespace='HGNC')
-egf = Protein(name='EGF', namespace='HGNC')
-ifna1 = Protein(name='IFNA1', namespace='HGNC')
-ifng = Protein(name='IFNG', namespace='HGNC')
-vcp = Protein(name='VCP', namespace='HGNC')
+ar = Protein(name='AR', namespace='hgnc')
+egf = Protein(name='EGF', namespace='hgnc')
+ifna1 = Protein(name='IFNA1', namespace='hgnc')
+ifng = Protein(name='IFNG', namespace='hgnc')
+vcp = Protein(name='VCP', namespace='hgnc')
 
-nfkb1 = Protein(name='NFKB1', namespace='HGNC')
-nfkb2 = Protein(name='NFKB2', namespace='HGNC')
-rel = Protein(name='REL', namespace='HGNC')
-rela = Protein(name='RELA', namespace='HGNC')
-relb = Protein(name='RELB', namespace='HGNC')
+nfkb1 = Protein(name='NFKB1', namespace='hgnc')
+nfkb2 = Protein(name='NFKB2', namespace='hgnc')
+rel = Protein(name='REL', namespace='hgnc')
+rela = Protein(name='RELA', namespace='hgnc')
+relb = Protein(name='RELB', namespace='hgnc')
 
 nfkb_complex = ComplexAbundance([nfkb1, nfkb2, rel, rela, relb])
 
-apoptosis = BiologicalProcess(namespace='GO', name='apoptotic process', identifier='GO:0006915')
+apoptosis = BiologicalProcess(namespace='go', name='apoptotic process', identifier='0006915')
 
 egf_graph.add_increases(
     ar,
diff --git a/src/pybel/examples/sialic_acid_example.py b/src/pybel/examples/sialic_acid_example.py
index 69cbf31c..a4a9c157 100644
--- a/src/pybel/examples/sialic_acid_example.py
+++ b/src/pybel/examples/sialic_acid_example.py
@@ -58,9 +58,9 @@ sialic_acid_graph = BELGraph(
 )
 
 sialic_acid_graph.namespace_url.update({
-    'HGNC': HGNC_URL,
-    'CHEBI': CHEBI_URL,
-    'GO': GO_URL,
+    'hgnc': HGNC_URL,
+    'chebi': CHEBI_URL,
+    'go': GO_URL,
 })
 
 sialic_acid_graph.annotation_url.update({
@@ -71,23 +71,23 @@ sialic_acid_graph.annotation_pattern.update({
     'Species': SPECIES_PATTERN,
 })
 
-sialic_acid = Abundance(name='sialic acid', namespace='CHEBI', identifier='26667')
+sialic_acid = Abundance(name='sialic acid', namespace='chebi', identifier='26667')
 cd33 = Protein(
     name='CD33',
-    namespace='HGNC',
+    namespace='hgnc',
     identifier='1659',
     xrefs=[
         Entity(namespace='uniprot', identifier='P20138'),
     ],
 )
 sialic_acid_cd33_complex = ComplexAbundance([sialic_acid, cd33])
-shp1 = Protein(namespace='HGNC', name='PTPN6', identifier='9658')
-shp2 = Protein(namespace='HGNC', name='PTPN11', identifier='9644')
-syk = Protein(namespace='HGNC', name='SYK', identifier='11491')
-dap12 = Protein(namespace='HGNC', name='TYROBP', identifier='12449')
-trem2 = Protein(namespace='HGNC', name='TREM2', identifier='17761')
-cd33_phosphorylated = Protein(name='CD33', namespace='HGNC', identifier='1659', variants=ProteinModification('Ph'))
-immune_response = BiologicalProcess(name='immune response', namespace='GO', identifier='0006955')
+shp1 = Protein(namespace='hgnc', name='PTPN6', identifier='9658')
+shp2 = Protein(namespace='hgnc', name='PTPN11', identifier='9644')
+syk = Protein(namespace='hgnc', name='SYK', identifier='11491')
+dap12 = Protein(namespace='hgnc', name='TYROBP', identifier='12449')
+trem2 = Protein(namespace='hgnc', name='TREM2', identifier='17761')
+cd33_phosphorylated = Protein(name='CD33', namespace='hgnc', identifier='1659', variants=ProteinModification('Ph'))
+immune_response = BiologicalProcess(name='immune response', namespace='go', identifier='0006955')
 
 sialic_acid_graph.add_increases(
     sialic_acid_cd33_complex,
diff --git a/src/pybel/grounding.py b/src/pybel/grounding.py
index 2b8bbcc8..e2f55b8e 100644
--- a/src/pybel/grounding.py
+++ b/src/pybel/grounding.py
@@ -51,7 +51,8 @@ After installation with ``pip install git+https://github.com/cthoyt/selventa-kno
 
 import logging
 import unittest
-from typing import Tuple, Union
+from collections import defaultdict
+from typing import Any, Mapping, Tuple, Union
 
 from protmapper.uniprot_client import get_id_from_mnemonic, get_mnemonic
 from pyobo.extract import get_id_name_mapping, get_name_id_mapping
@@ -61,12 +62,12 @@ from pyobo.xrefdb.sources.famplex import get_remapping
 from tqdm import tqdm
 
 from pybel.constants import (
-    CONCEPT, FUSION, GMOD, IDENTIFIER, KIND, MEMBERS, NAME, NAMESPACE, PARTNER_3P, PARTNER_5P, PMOD, PRODUCTS,
-    REACTANTS, VARIANTS,
+    ACTIVITY, ANNOTATIONS, CONCEPT, EFFECT, FROM_LOC, FUSION, GMOD, IDENTIFIER, KIND, LOCATION, MEMBERS, MODIFIER, NAME,
+    NAMESPACE, OBJECT, PARTNER_3P, PARTNER_5P, PMOD, PRODUCTS, REACTANTS, SUBJECT, TO_LOC, TRANSLOCATION, VARIANTS,
 )
 from pybel.io import from_nodelink, to_nodelink
-from pybel.language import gmod_mappings, pmod_mappings
-from pybel.struct import BELGraph
+from pybel.language import Entity, activity_mapping, compartment_mapping, gmod_mappings, pmod_mappings
+from pybel.struct import BELGraph, get_annotations, get_namespaces, get_ungrounded_nodes
 
 __all__ = [
     'ground',
@@ -78,7 +79,10 @@ SKIP = {'ncbigene', 'pubchem.compound'}
 NO_NAMES = {'fplx', 'eccode', 'dbsnp'}
 
 # TODO will get updated
+#: A mapping of (prefix, name) pairs to (prefix, identifier, name) triples
 _NAME_REMAPPING = get_remapping()
+
+#: A mapping of (prefix, identifier) pairs to (prefix, identifier, name) triples
 _ID_REMAPPING = {}
 
 
@@ -90,66 +94,193 @@ def _get_id_remapping(prefix: str, identifier: str) -> Union[Tuple[str, str, str
     return _ID_REMAPPING.get((prefix, identifier), (None, None, None))
 
 
-def ground(graph: BELGraph) -> BELGraph:
+def ground(graph: BELGraph, remove_ungrounded: bool = True) -> BELGraph:
     """Ground all entities in a BEL graph."""
     j = to_nodelink(graph)
     ground_nodelink(j)
-    return from_nodelink(j)
+    graph = from_nodelink(j)
+
+    remove_unused_annotation_metadata(graph)
+
+    if remove_ungrounded:
+        ungrounded_nodes = get_ungrounded_nodes(graph)
+        graph.remove_nodes_from(ungrounded_nodes)
+        graph.namespace_url.clear()
+        graph.namespace_pattern.clear()
+        graph.namespace_pattern.update({
+            namespace: '.*'
+            for namespace in get_namespaces(graph)
+        })
+
+        graph.annotation_url.clear()
+        graph.annotation_pattern.clear()
+        graph.annotation_list.clear()
+        graph.annotation_pattern.update({
+            annotation: '.*'
+            for annotation in get_annotations(graph)
+        })
+
+    return graph
+
+
+def remove_unused_annotation_metadata(graph) -> None:
+    used_annotations = get_annotations(graph)
+
+    unused_patterns = set(graph.annotation_pattern) - used_annotations
+    for annotation in unused_patterns:
+        logger.warning('deleting unused annotation pattern: %s', annotation)
+        del graph.annotation_pattern[annotation]
+
+    unused_urls = set(graph.annotation_pattern) - used_annotations
+    for annotation in unused_urls:
+        logger.warning('deleting unused annotation URL: %s', annotation)
+        del graph.annotation_url[annotation]
+
+    unused_lists = set(graph.annotation_list) - used_annotations
+    for annotation in unused_lists:
+        logger.warning('deleting unused annotation list: %s', annotation)
+        del graph.annotation_list[annotation]
 
 
 def ground_nodelink(graph_nodelink_dict) -> None:
     """Ground entities in a nodelink data structure."""
     name = graph_nodelink_dict.get('graph', {}).get('name', 'graph')
+
+    for data in tqdm(graph_nodelink_dict['links'], desc='grounding edges in {}'.format(name)):
+        _process_edge_side(data.get(SUBJECT))
+        _process_edge_side(data.get(OBJECT))
+        _process_annotations(data)
+
     for node in tqdm(graph_nodelink_dict['nodes'], desc='grounding nodes in {}'.format(name)):
         _process_node(node)
 
-    # TODO ground entities in edges! what about all those fabulous activities?
+
+_BEL_ANNOTATION_PREFIX_MAP = {
+    'MeSHDisease': 'mesh',
+    'MeSHAnatomy': 'mesh',
+    'Species': 'ncbitaxon',
+    'Disease': 'doid',
+    'Cell': 'cl',
+}
+_UNHANDLED_ANNOTATION = set()
+
+
+def _process_annotations(data, add_free_annotations: bool = False):
+    x = []
+    y = []
+    for prefix, names in data.get(ANNOTATIONS, {}).items():
+        if prefix == 'CellLine':
+            efo_name_to_id = get_name_id_mapping('efo')
+            # clo_name_to_id = get_name_id_mapping('clo')  # FIXME implement CLO import
+            for name, polarity in names.items():
+                prefix, identifier = 'efo', efo_name_to_id.get(name)
+                # if identifier is None:
+                #     prefix, identifier = 'clo', clo_name_to_id.get(name)
+                if identifier is not None:
+                    x.append((Entity(namespace=prefix, identifier=identifier, name=name), polarity))
+                else:
+                    y.append((prefix, identifier, polarity))
+        elif prefix in _BEL_ANNOTATION_PREFIX_MAP:
+            prefix = _BEL_ANNOTATION_PREFIX_MAP[prefix]
+            name_id = get_name_id_mapping(prefix)
+            for name, polarity in names.items():
+                identifier = name_id.get(name)
+                if identifier:
+                    x.append((Entity(namespace=prefix, identifier=identifier, name=name), polarity))
+                else:
+                    y.append((prefix, name, polarity))
+        else:
+            if prefix not in _UNHANDLED_ANNOTATION:
+                logger.warning('unhandled annotation: %s', prefix)
+                _UNHANDLED_ANNOTATION.add(prefix)
+            for name, polarity in names.items():
+                y.append((prefix, name, polarity))
+
+    data[ANNOTATIONS] = defaultdict(dict)
+    for entity, polarity in x:
+        data[ANNOTATIONS][entity.namespace][entity.identifier] = polarity
+
+    if add_free_annotations:
+        data['free_annotations'] = defaultdict(dict)
+        for prefix, name, polarity in y:
+            data['free_annotations'][prefix][name] = polarity
+
+
+def _process_edge_side(side_data) -> bool:
+    """Process an edge JSON object, in place."""
+    if side_data is None:
+        return True
+
+    modifier = side_data.get(MODIFIER)
+    effect = side_data.get(EFFECT)
+
+    if modifier == ACTIVITY and effect is not None:
+        _process_concept(concept=effect)
+    elif modifier == TRANSLOCATION and effect is not None:
+        _process_concept(concept=effect[FROM_LOC])
+        _process_concept(concept=effect[TO_LOC])
+
+    location = side_data.get(LOCATION)
+    if location is not None:
+        _process_concept(concept=location)
 
 
 _UNHANDLED_NAMESPACES = set()
 
 
-def _process_node(node) -> None:
-    """Process a node JSON object, in place."""
+def _process_node(node: Mapping[str, Any]) -> bool:
+    """Process a node JSON object, in place.
+
+    :return: If all parts of the node were successfully grounded
+    """
+    success = True
     if CONCEPT in node:
-        _process_concept(node=node)
+        success = success and _process_concept(concept=node[CONCEPT], node=node)
     if VARIANTS in node:
-        _process_list(node[VARIANTS])
+        success = success and _process_list(node[VARIANTS])
     if MEMBERS in node:
-        _process_list(node[MEMBERS])
+        success = success and _process_list(node[MEMBERS])
     if REACTANTS in node:
-        _process_list(node[REACTANTS])
+        success = success and _process_list(node[REACTANTS])
     if PRODUCTS in node:
-        _process_list(node[PRODUCTS])
+        success = success and _process_list(node[PRODUCTS])
     if FUSION in node:
-        _process_fusion(node[FUSION])
+        success = success and _process_fusion(node[FUSION])
+    return success
 
 
-def _process_concept(*, node) -> None:
+def _process_concept(*, concept, node=None) -> bool:
     """Process a node JSON object."""
-    concept = node[CONCEPT]
     namespace = concept[NAMESPACE]
     if namespace.lower() in {'text', 'fixme'}:
-        return
+        return False
 
     prefix = normalize_prefix(namespace)
     if prefix is None:
-        logger.warning('could not normalize namespace: %s', namespace)
-        return
+        logger.warning('could not normalize namespace: %s ! %s', namespace, concept.name)
+        return False
 
     concept[NAMESPACE] = prefix
 
     identifier = concept.get(IDENTIFIER)
     name = concept.get(NAME)
     if identifier:  # don't trust whatever was put for the name, even if it's available
-        _handle_identifier_not_name(concept=concept, prefix=prefix, identifier=identifier)
+        map_success = _handle_identifier_not_name(concept=concept, prefix=prefix, identifier=identifier)
     else:
-        _handle_name_and_not_identifier(node=node, concept=concept, prefix=prefix, name=name)
+        map_success = _handle_name_and_not_identifier(concept=concept, prefix=prefix, name=name, node=node)
+
+    if not map_success:
+        return False
+
     _remap_by_identifier(concept)
+    return True
 
 
 def _remap_by_identifier(concept) -> bool:
-    namespace, identifier = concept[NAMESPACE], concept[IDENTIFIER]
+    identifier = concept.get(IDENTIFIER)
+    if identifier is None:
+        return False
+    namespace = concept[NAMESPACE]
     logger.debug('attempting to remap %s:%s', namespace, identifier)
     remapped_prefix, remapped_identifier, remapped_name = _get_id_remapping(namespace, identifier)
     logger.debug('remapping result %s:%s ! %s', remapped_prefix, remapped_identifier, remapped_name)
@@ -161,41 +292,46 @@ def _remap_by_identifier(concept) -> bool:
     return False
 
 
-def _handle_identifier_not_name(*, concept, prefix, identifier) -> None:
+def _handle_identifier_not_name(*, concept, prefix, identifier) -> bool:
+    if prefix in NO_NAMES:
+        concept[NAME] = concept[IDENTIFIER]
+        return True
+
     if prefix == 'uniprot':
         concept[NAME] = get_mnemonic(identifier)
-        return
+        return True
 
     id_name_mapping = get_id_name_mapping(prefix)
     if id_name_mapping is None:
         logger.warning('could not get names for prefix %s', prefix)
-        return
+        return False
     name = id_name_mapping.get(identifier)
     if name is None:
         logger.warning('could not get name for %s:%s', prefix, identifier)
-        return
+        return False
     concept[NAME] = name
-    return
+
+    return True
 
 
-def _handle_name_and_not_identifier(*, node, concept, prefix, name) -> None:
+def _handle_name_and_not_identifier(*, concept, prefix, name, node=None) -> bool:
     remapped_prefix, remapped_identifier, remapped_name = _get_name_remapping(prefix, name)
     if remapped_prefix:
         concept[NAMESPACE] = remapped_prefix
         concept[IDENTIFIER] = remapped_identifier
         concept[NAME] = remapped_name
-        return
+        return True
 
     # Some namespaces are just too much of a problem at the moment to look up
     if prefix in SKIP:
-        return
+        return False
 
     concept[NAMESPACE] = prefix
     if prefix in NO_NAMES:
         concept[IDENTIFIER] = name
-        return
+        return True
 
-    if prefix == 'bel' and KIND in node:
+    if prefix == 'bel' and node is not None and KIND in node:
         kind = node[KIND]
         if kind == PMOD and name in pmod_mappings:
             # the 0th position xref is the preferred one (usually GO)
@@ -207,23 +343,38 @@ def _handle_name_and_not_identifier(*, node, concept, prefix, name) -> None:
         concept[NAMESPACE] = _mapped[NAMESPACE]
         concept[IDENTIFIER] = _mapped[IDENTIFIER]
         concept[NAME] = _mapped[NAME]
-        return
+        return True
+    elif prefix == 'bel' and name in activity_mapping:
+        _mapped = activity_mapping[name]
+        concept[NAMESPACE] = _mapped[NAMESPACE]
+        concept[IDENTIFIER] = _mapped[IDENTIFIER]
+        concept[NAME] = _mapped[NAME]
+        return True
+    elif prefix == 'bel' and name in compartment_mapping:
+        _mapped = compartment_mapping[name]
+        concept[NAMESPACE] = _mapped[NAMESPACE]
+        concept[IDENTIFIER] = _mapped[IDENTIFIER]
+        concept[NAME] = _mapped[NAME]
+        return True
+    elif prefix == 'bel':
+        logger.warning('could not figure out how to map bel ! %s', name)
+        return False
 
     if prefix == 'uniprot':
         # assume identifier given as name
         identifier = get_id_from_mnemonic(name)
         if identifier is not None:
             concept[IDENTIFIER] = identifier
-            return
+            return True
 
         mnemomic = get_mnemonic(name, web_fallback=True)
         if mnemomic is not None:
             concept[IDENTIFIER] = name
             concept[NAME] = mnemomic
-            return
+            return True
 
         logger.warning('could not interpret uniprot name: %s', name)
-        return
+        return False
 
     try:
         id_name_mapping = get_name_id_mapping(prefix)
@@ -232,24 +383,28 @@ def _handle_name_and_not_identifier(*, node, concept, prefix, name) -> None:
 
     if id_name_mapping is None:
         logger.warning('unhandled namespace in %s ! %s', prefix, name)
-        return
+        return False
 
     identifier = id_name_mapping.get(name)
     if identifier is None:
         logger.warning('could not find name %s in namespace %s', name, prefix)
-        return
+        return False
 
     concept[IDENTIFIER] = identifier
+    return True
 
 
-def _process_fusion(fusion) -> None:
-    _process_node(fusion[PARTNER_3P])
-    _process_node(fusion[PARTNER_5P])
+def _process_fusion(fusion) -> bool:
+    success_3p = _process_node(fusion[PARTNER_3P])
+    success_5p = _process_node(fusion[PARTNER_5P])
+    return success_3p and success_5p
 
 
-def _process_list(members) -> None:
+def _process_list(members) -> bool:
+    success = True
     for member in members:
-        _process_node(member)
+        success = success and _process_node(member)
+    return success
 
 
 class TestGround(unittest.TestCase):
@@ -307,7 +462,7 @@ class TestGround(unittest.TestCase):
         for name, expected, result in r:
             with self.subTest(name=name):
                 self.assertIn(expected[CONCEPT][NAMESPACE], SYNONYM_TO_KEY)
-                _process_concept(node=result)
+                _process_concept(concept=result[CONCEPT], node=result)
                 self.assertEqual(expected, result)
 
     def test_process_nodes(self):
diff --git a/src/pybel/io/bel_commons_client.py b/src/pybel/io/bel_commons_client.py
index 84ada865..397a6185 100644
--- a/src/pybel/io/bel_commons_client.py
+++ b/src/pybel/io/bel_commons_client.py
@@ -61,7 +61,7 @@ def to_bel_commons(
     host: Optional[str] = None,
     user: Optional[str] = None,
     password: Optional[str] = None,
-    public: bool = False,
+    public: bool = True,
 ) -> requests.Response:
     """Send a graph to the receiver service and returns the :mod:`requests` response object.
 
diff --git a/src/pybel/io/nodelink.py b/src/pybel/io/nodelink.py
index d5e60716..19f9f7a2 100644
--- a/src/pybel/io/nodelink.py
+++ b/src/pybel/io/nodelink.py
@@ -12,9 +12,12 @@ from typing import Any, Mapping, TextIO, Union
 from networkx.utils import open_file
 
 from .utils import ensure_version
-from ..constants import FUSION, GRAPH_ANNOTATION_LIST, MEMBERS, PARTNER_3P, PARTNER_5P, PRODUCTS, REACTANTS
+from ..constants import (
+    FUSION, GRAPH_ANNOTATION_LIST, MEMBERS, OBJECT, PARTNER_3P, PARTNER_5P, PRODUCTS, REACTANTS, SUBJECT,
+)
 from ..dsl import BaseEntity
 from ..struct import BELGraph
+from ..struct.graph import _handle_modifier
 from ..tokens import parse_result_to_dsl
 from ..utils import hash_edge, tokenize_version
 
@@ -171,6 +174,12 @@ def _from_nodelink_json_helper(data: Mapping[str, Any]) -> BELGraph:
             for k, v in data.items()
             if k not in {'source', 'target', 'key'}
         }
+
+        for side in (SUBJECT, OBJECT):
+            side_data = edge_data.get(side)
+            if side_data:
+                _handle_modifier(side_data)
+
         graph.add_edge(u, v, key=hash_edge(u, v, edge_data), **edge_data)
 
     return graph
diff --git a/src/pybel/manager/cache_manager.py b/src/pybel/manager/cache_manager.py
index a40ef9e3..952c4e2b 100644
--- a/src/pybel/manager/cache_manager.py
+++ b/src/pybel/manager/cache_manager.py
@@ -9,9 +9,8 @@ enable this option, but can specify a database location if they choose.
 import json
 import logging
 import time
-from copy import deepcopy
 from itertools import chain
-from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple, Union
+from typing import Any, Iterable, List, Mapping, Optional, Set, Tuple
 
 import requests
 import sqlalchemy
@@ -24,29 +23,23 @@ from .base_manager import BaseManager, build_engine_session
 from .exc import EdgeAddError
 from .lookup_manager import LookupManager
 from .models import (
-    Author, Citation, Edge, Evidence, Modification, Namespace, NamespaceEntry, Network, Node, Property, edge_annotation,
-    edge_property, network_edge, network_node,
+    Author, Citation, Edge, Evidence, Namespace, NamespaceEntry, Network, Node, edge_annotation, network_edge,
+    network_node,
 )
 from .query_manager import QueryManager
 from .utils import extract_shared_optional, extract_shared_required, update_insert_values
 from ..constants import (
-    ACTIVITY, ANNOTATIONS, BEL_DEFAULT_NAMESPACE, CITATION, CITATION_AUTHORS, CITATION_DATE, CITATION_DB,
-    CITATION_DB_NAME, CITATION_FIRST_AUTHOR, CITATION_IDENTIFIER, CITATION_ISSUE, CITATION_JOURNAL,
-    CITATION_LAST_AUTHOR, CITATION_PAGES, CITATION_TYPE_PUBMED, CITATION_VOLUME, CONCEPT, DEGRADATION, EFFECT, EVIDENCE,
-    FRAGMENT, FRAGMENT_MISSING, FRAGMENT_START, FRAGMENT_STOP, FUSION, FUSION_REFERENCE, FUSION_START, FUSION_STOP,
-    GMOD, HGVS, KIND, LOCATION, METADATA_INSERT_KEYS, MODIFIER, NAME, NAMESPACE, OBJECT, PARTNER_3P, PARTNER_5P, PMOD,
-    PMOD_CODE, PMOD_POSITION, RANGE_3P, RANGE_5P, RELATION, SUBJECT, TRANSLOCATION, UNQUALIFIED_EDGES, VARIANTS,
-    belns_encodings, get_cache_connection,
+    ANNOTATIONS, BEL_DEFAULT_NAMESPACE, CITATION, CITATION_DB, CITATION_IDENTIFIER, CITATION_TYPE_PUBMED, EVIDENCE,
+    METADATA_INSERT_KEYS, OBJECT, RELATION, SUBJECT, UNQUALIFIED_EDGES, belns_encodings, get_cache_connection,
 )
-from ..dsl import BaseEntity
+from ..dsl import BaseConcept, BaseEntity
 from ..language import (
-    BEL_DEFAULT_NAMESPACE_URL, BEL_DEFAULT_NAMESPACE_VERSION, activity_mapping, compartment_mapping, gmod_mappings,
-    pmod_mappings,
+    BEL_DEFAULT_NAMESPACE_URL, BEL_DEFAULT_NAMESPACE_VERSION, Entity, activity_mapping, compartment_mapping,
+    gmod_mappings, pmod_mappings,
 )
 from ..struct.graph import AnnotationsDict, BELGraph
 from ..struct.operations import union
 from ..typing import EdgeData
-from ..utils import hash_dump, parse_datetime
 
 __all__ = [
     'Manager',
@@ -286,29 +279,29 @@ class NamespaceManager(BaseManager):
         entry_filter = and_(Namespace.url == url, NamespaceEntry.identifier == identifier)
         return self.session.query(NamespaceEntry).join(Namespace).filter(entry_filter).one_or_none()
 
-    def get_or_create_regex_namespace_entry(self, namespace: str, pattern: str, name: str) -> NamespaceEntry:
+    def get_or_create_regex_namespace_entry(self, *, pattern: str, concept: Entity) -> NamespaceEntry:
         """Get a namespace entry from a regular expression.
 
         Need to commit after!
 
-        :param namespace: The name of the namespace
         :param pattern: The regular expression pattern for the namespace
-        :param name: The entry to get
+        :param concept: The prefix/identifier/name triple
         """
-        namespace = self.ensure_regex_namespace(namespace, pattern)
+        namespace = self.ensure_regex_namespace(concept.namespace, pattern)
 
-        n_filter = and_(Namespace.pattern == pattern, NamespaceEntry.name == name)
+        n_filter = and_(Namespace.pattern == pattern, NamespaceEntry.name == concept.name)
 
-        name_model = self.session.query(NamespaceEntry).join(Namespace).filter(n_filter).one_or_none()
+        namespace_entry = self.session.query(NamespaceEntry).join(Namespace).filter(n_filter).one_or_none()
 
-        if name_model is None:
-            name_model = NamespaceEntry(
+        if namespace_entry is None:
+            namespace_entry = NamespaceEntry(
                 namespace=namespace,
-                name=name,
+                name=concept.name,
+                identifier=concept.identifier,
             )
-            self.session.add(name_model)
+            self.session.add(namespace_entry)
 
-        return name_model
+        return namespace_entry
 
     def list_annotations(self) -> List[Namespace]:
         """List all annotations."""
@@ -437,11 +430,6 @@ class NetworkManager(NamespaceManager):
             synchronize_session=False,
         )
 
-        # delete the edge-to-property mappings for the to-be-orphaned edges
-        self.session.query(edge_property).filter(edge_property.c.edge_id.in_(edge_ids)).delete(
-            synchronize_session=False,
-        )
-
         # delete the edge-to-annotation mappings for the to-be-orphaned edges
         self.session.query(edge_annotation).filter(edge_annotation.c.edge_id.in_(edge_ids)).delete(
             synchronize_session=False,
@@ -581,7 +569,7 @@ class InsertManager(NamespaceManager, LookupManager):
     def insert_graph(
         self,
         graph: BELGraph,
-        use_tqdm: bool = False,
+        use_tqdm: bool = True,
         tqdm_kwargs: Optional[Mapping[str, Any]] = None,
     ) -> Network:
         """Insert a graph in the database and returns the corresponding Network model.
@@ -617,13 +605,11 @@ class InsertManager(NamespaceManager, LookupManager):
         for annotation_url in annotation_urls:
             self.get_or_create_annotation(annotation_url)
 
-        network = Network(
-            **{
-                key: value
-                for key, value in graph.document.items()
-                if key in METADATA_INSERT_KEYS
-            },
-        )
+        network = Network(**{
+            key: value
+            for key, value in graph.document.items()
+            if key in METADATA_INSERT_KEYS
+        })
 
         network.store_bel(graph)
 
@@ -771,12 +757,13 @@ class InsertManager(NamespaceManager, LookupManager):
     def _get_annotation_entries_from_data(self, graph: BELGraph, data: EdgeData) -> Optional[List[NamespaceEntry]]:
         """Get the annotation entries from an edge data dictionary."""
         annotations_dict = data.get(ANNOTATIONS)
-        if annotations_dict is not None:
-            return [
-                entry
-                for url, names in self._iter_from_annotations_dict(graph, annotations_dict=annotations_dict)
-                for entry in self.get_annotation_entries_by_names(url, names)
-            ]
+        if annotations_dict is None:
+            return
+        rv = []
+        for url, names in self._iter_from_annotations_dict(graph, annotations_dict=annotations_dict):
+            for entry in self.get_annotation_entries_by_names(url, names):
+                rv.append(entry)
+        return rv
 
     def _add_qualified_edge(
         self,
@@ -794,11 +781,6 @@ class InsertManager(NamespaceManager, LookupManager):
             db_id=citation_dict[CITATION_IDENTIFIER],
         )
         evidence = self.get_or_create_evidence(citation, data[EVIDENCE])
-
-        properties = self.get_or_create_properties(graph, data)
-        if properties is None:
-            return
-
         annotations = self._get_annotation_entries_from_data(graph, data)
 
         return self.get_or_create_edge(
@@ -809,7 +791,6 @@ class InsertManager(NamespaceManager, LookupManager):
             md5=key,
             data=data,
             evidence=evidence,
-            properties=properties,
             annotations=annotations,
         )
 
@@ -858,13 +839,14 @@ class InsertManager(NamespaceManager, LookupManager):
 
         node_model = Node._start_from_base_entity(node)
 
-        concept = node.get(CONCEPT)
-        if concept is None:
-            pass
+        if not isinstance(node, BaseConcept):
+            self.session.add(node_model)
+            self.object_cache_node[node_md5] = node_model
+            return node_model
 
-        elif concept[NAMESPACE] in graph.namespace_url:
-            url = graph.namespace_url[concept[NAMESPACE]]
-            name = concept[NAME]
+        if node.namespace in graph.namespace_url:
+            url = graph.namespace_url[node.namespace]
+            name = node.name
             entry = self.get_namespace_entry(url, name)
 
             if entry is None:
@@ -874,30 +856,18 @@ class InsertManager(NamespaceManager, LookupManager):
             self.session.add(entry)
             node_model.namespace_entry = entry
 
-        elif concept[NAMESPACE] in graph.namespace_pattern:
-            name = concept[NAME]
-            pattern = graph.namespace_pattern[concept[NAMESPACE]]
-            entry = self.get_or_create_regex_namespace_entry(concept[NAMESPACE], pattern, name)
-
+        elif node.namespace in graph.namespace_pattern:
+            entry = self.get_or_create_regex_namespace_entry(
+                concept=node.entity,
+                pattern=graph.namespace_pattern[node.namespace],
+            )
             self.session.add(entry)
             node_model.namespace_entry = entry
 
         else:
-            logger.warning("No reference in BELGraph for namespace: {}".format(concept[NAMESPACE]))
+            logger.warning("No reference in BELGraph for namespace: {}".format(node.namespace))
             return
 
-        if VARIANTS in node or FUSION in node:
-            node_model.is_variant = True
-            node_model.has_fusion = FUSION in node
-
-            modifications = self.get_or_create_modification(graph, node)
-
-            if modifications is None:
-                logger.warning('could not create %s because had an uncachable modification', node.as_bel())
-                return
-
-            node_model.modifications = modifications
-
         self.session.add(node_model)
         self.object_cache_node[node_md5] = node_model
         return node_model
@@ -930,7 +900,6 @@ class InsertManager(NamespaceManager, LookupManager):
         data: EdgeData,
         evidence: Optional[Evidence] = None,
         annotations: Optional[List[NamespaceEntry]] = None,
-        properties: Optional[List[Property]] = None,
     ) -> Edge:
         """Create an edge if it does not exist, or return it if it does.
 
@@ -941,7 +910,6 @@ class InsertManager(NamespaceManager, LookupManager):
         :param md5: The MD5 hash of the edge as a string
         :param data: The PyBEL data dictionary
         :param evidence: Evidence object that proves the given relation
-        :param properties: List of all properties that belong to the edge
         :param annotations: List of all annotations that belong to the edge
         """
         if md5 in self.object_cache_edge:
@@ -957,17 +925,17 @@ class InsertManager(NamespaceManager, LookupManager):
 
         edge = Edge(
             source=source,
+            source_modifier=data.get(SUBJECT),
             target=target,
+            target_modifier=data.get(OBJECT),
             relation=relation,
             bel=bel,
             md5=md5,
-            data=json.dumps(data),
+            data=data,
         )
 
         if evidence is not None:
             edge.evidence = evidence
-        if properties is not None:
-            edge.properties = properties
         if annotations is not None:
             edge.annotations = annotations
 
@@ -1023,229 +991,6 @@ class InsertManager(NamespaceManager, LookupManager):
         self.session.add(author)
         return author
 
-    def get_modification_by_hash(self, md5: str) -> Optional[Modification]:
-        """Get a modification by a MD5 hash."""
-        return self.session.query(Modification).filter(Modification.md5 == md5).one_or_none()
-
-    def get_or_create_modification(self, graph: BELGraph, node: BaseEntity) -> Optional[List[Modification]]:
-        """Create a list of node modification objects that belong to the node described by node_data.
-
-        Return ``None`` if the list can not be constructed, and the node should also be skipped.
-
-        :param graph: A BEL graph
-        :param dict node: Describes the given node and contains is_variant information
-        :return: A list of modification objects belonging to the given node
-        """
-        modification_list = []
-        if FUSION in node:
-            mod_type = FUSION
-            node = node[FUSION]
-
-            partner_3p_concept = node[PARTNER_3P][CONCEPT]
-            p3_namespace_url = graph.namespace_url[partner_3p_concept[NAMESPACE]]
-            p3_name = partner_3p_concept[NAME]
-            p3_namespace_entry = self.get_namespace_entry(p3_namespace_url, p3_name)
-
-            if p3_namespace_entry is None:
-                logger.warning('Could not find namespace entry %s %s', p3_namespace_url, p3_name)
-                return  # FIXME raise?
-
-            partner_5p_concept = node[PARTNER_5P][CONCEPT]
-            p5_namespace_url = graph.namespace_url[partner_5p_concept[NAMESPACE]]
-            p5_name = partner_5p_concept[NAME]
-            p5_namespace_entry = self.get_namespace_entry(p5_namespace_url, p5_name)
-
-            if p5_namespace_entry is None:
-                logger.warning('Could not find namespace entry %s %s', p5_namespace_url, p5_name)
-                return  # FIXME raise?
-
-            fusion_dict = {
-                'type': mod_type,
-                'p3_partner': p3_namespace_entry,
-                'p5_partner': p5_namespace_entry,
-            }
-
-            node_range_3p = node.get(RANGE_3P)
-            if node_range_3p and FUSION_REFERENCE in node_range_3p:
-                fusion_dict.update({
-                    'p3_reference': node_range_3p[FUSION_REFERENCE],
-                    'p3_start': node_range_3p[FUSION_START],
-                    'p3_stop': node_range_3p[FUSION_STOP],
-                })
-
-            node_range_5p = node.get(RANGE_5P)
-            if node_range_5p and FUSION_REFERENCE in node_range_5p:
-                fusion_dict.update({
-                    'p5_reference': node_range_5p[FUSION_REFERENCE],
-                    'p5_start': node_range_5p[FUSION_START],
-                    'p5_stop': node_range_5p[FUSION_STOP],
-                })
-
-            modification_list.append(fusion_dict)
-
-        else:
-            for variant in node[VARIANTS]:
-                mod_type = variant[KIND].strip()
-                if mod_type == HGVS:
-                    modification_list.append({
-                        'type': mod_type,
-                        'variantString': variant[HGVS],
-                    })
-
-                elif mod_type == FRAGMENT:
-                    if FRAGMENT_MISSING in variant:
-                        modification_list.append({
-                            'type': mod_type,
-                        })
-                    else:
-                        modification_list.append({
-                            'type': mod_type,
-                            'p3_start': variant[FRAGMENT_START],
-                            'p3_stop': variant[FRAGMENT_STOP],
-                        })
-
-                elif mod_type in {GMOD, PMOD}:
-                    variant_identifier = variant[CONCEPT]
-                    namespace_url = _normalize_url(graph, variant_identifier[NAMESPACE])
-                    mod_entry = self.get_namespace_entry(
-                        namespace_url,
-                        variant_identifier[NAME],
-                    )
-
-                    if mod_type == GMOD:
-                        modification_list.append({
-                            'type': mod_type,
-                            'identifier': mod_entry,
-                        })
-                    if mod_type == PMOD:
-                        modification_list.append({
-                            'type': mod_type,
-                            'identifier': mod_entry,
-                            'residue': variant[PMOD_CODE].strip() if PMOD_CODE in variant else None,
-                            'position': variant[PMOD_POSITION] if PMOD_POSITION in variant else None,
-                        })
-
-        modifications = []
-        for modification in modification_list:
-            mod_hash = hash_dump(modification)
-
-            mod = self.object_cache_modification.get(mod_hash)
-            if mod is None:
-                mod = self.get_modification_by_hash(mod_hash)
-                if not mod:
-                    modification['md5'] = mod_hash
-                    mod = Modification(**modification)
-
-                self.object_cache_modification[mod_hash] = mod
-            modifications.append(mod)
-
-        return modifications
-
-    def get_property_by_hash(self, property_hash: str) -> Optional[Property]:
-        """Get a property by its hash if it exists."""
-        return self.session.query(Property).filter(Property.md5 == property_hash).one_or_none()
-
-    def _make_property_from_dict(self, property_def: Dict) -> Property:
-        """Build an edge property from a dictionary."""
-        property_hash = hash_dump(property_def)
-
-        edge_property_model = self.object_cache_property.get(property_hash)
-        if edge_property_model is None:
-            edge_property_model = self.get_property_by_hash(property_hash)
-
-            if not edge_property_model:
-                property_def['md5'] = property_hash
-                edge_property_model = Property(**property_def)
-
-            self.object_cache_property[property_hash] = edge_property_model
-
-        return edge_property_model
-
-    # TODO make for just single property then loop with other fn.
-    def get_or_create_properties(self, graph: BELGraph, edge_data: EdgeData) -> Optional[List[Property]]:
-        """Create a list of edge subject/object property models.
-
-        Return None if the property cannot be constructed due to missing cache entries.
-
-        :return: A list of all subject and object properties of the edge
-        """
-        property_list = []
-        for participant in (SUBJECT, OBJECT):
-            participant_data = edge_data.get(participant)
-            if participant_data is None:
-                continue
-
-            location = participant_data.get(LOCATION)
-            if location is not None:
-                location_property_dict = {
-                    'is_subject': participant == SUBJECT,
-                    'modifier': LOCATION,
-                }
-
-                location_namespace = location[NAMESPACE]
-
-                namespace_url = graph.namespace_url[location_namespace]
-                participant_name = location[NAME]
-                location_property_dict['effect'] = self.get_namespace_entry(namespace_url, participant_name)
-                if location_property_dict['effect'] is None:
-                    raise IndexError('did not get {}: {}'.format(namespace_url, participant_name))
-
-                property_list.append(location_property_dict)
-
-            modifier = participant_data.get(MODIFIER)
-            if modifier is not None:
-                modifier_property_dict = {
-                    'is_subject': participant == SUBJECT,
-                    'modifier': modifier,
-                }
-
-                if modifier == TRANSLOCATION:
-                    for effect_type, effect_value in participant_data.get(EFFECT, {}).items():
-                        tmp_dict = deepcopy(modifier_property_dict)
-                        tmp_dict['relative_key'] = effect_type
-
-                        if NAMESPACE not in effect_value:
-                            tmp_dict['propValue'] = effect_value
-                            raise ValueError('shouldnt use propValue')
-                        else:
-                            effect_namespace = effect_value[NAMESPACE]
-
-                            if effect_namespace in graph.namespace_url:
-                                namespace_url = graph.namespace_url[effect_namespace]
-                            elif effect_namespace == BEL_DEFAULT_NAMESPACE:
-                                namespace_url = BEL_DEFAULT_NAMESPACE_URL
-                            else:
-                                logger.warning('namespace not enumerated in modifier %s', effect_namespace)
-                                return
-
-                            effect_name = effect_value[NAME]
-                            tmp_dict['effect'] = self.get_namespace_entry(namespace_url, effect_name)
-
-                            if tmp_dict['effect'] is None:
-                                logger.warning('could not find tloc() %s %s', namespace_url, effect_name)
-                                return  # FIXME raise?
-
-                        property_list.append(tmp_dict)
-
-                elif modifier == ACTIVITY:
-                    effect = participant_data.get(EFFECT)
-                    if effect is not None:
-                        namespace_url = _normalize_url(graph, effect[NAMESPACE])
-                        modifier_property_dict['effect'] = self.get_namespace_entry(namespace_url, effect[NAME])
-
-                    property_list.append(modifier_property_dict)
-
-                elif modifier == DEGRADATION:
-                    property_list.append(modifier_property_dict)
-
-                else:
-                    raise ValueError('unknown modifier: {}'.format(modifier))
-
-        return [
-            self._make_property_from_dict(property_def)
-            for property_def in property_list
-        ]
-
 
 class _Manager(QueryManager, InsertManager, NetworkManager):
     """A wrapper around PyBEL managers that can be directly instantiated with an engine and session."""
diff --git a/src/pybel/manager/database_io.py b/src/pybel/manager/database_io.py
index f4f5e6ef..e19475d0 100644
--- a/src/pybel/manager/database_io.py
+++ b/src/pybel/manager/database_io.py
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
 def to_database(
     graph,
     manager: Optional[Manager] = None,
-    use_tqdm: bool = False,
+    use_tqdm: bool = True,
 ):
     """Store a graph in a database.
 
diff --git a/src/pybel/manager/models.py b/src/pybel/manager/models.py
index ce59e034..068c3b2d 100644
--- a/src/pybel/manager/models.py
+++ b/src/pybel/manager/models.py
@@ -8,20 +8,18 @@ from collections import defaultdict
 from typing import Any, Iterable, Mapping, Optional, Tuple
 
 from sqlalchemy import (
-    Boolean, Column, Date, DateTime, ForeignKey, Integer, LargeBinary, String, Table, Text, UniqueConstraint,
+    Boolean, Column, Date, DateTime, ForeignKey, Integer, JSON, LargeBinary, String, Table, Text, UniqueConstraint,
 )
 from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.orm import backref, relationship
 
-from .utils import int_or_str
 from ..constants import (
     CITATION, CITATION_AUTHORS, CITATION_DATE, CITATION_DB, CITATION_DB_NAME, CITATION_FIRST_AUTHOR,
     CITATION_IDENTIFIER, CITATION_JOURNAL, CITATION_LAST_AUTHOR, CITATION_PAGES, CITATION_TYPE_PUBMED, CITATION_VOLUME,
-    EFFECT, EVIDENCE, FRAGMENT, FUSION, GMOD, HGVS, IDENTIFIER, LOCATION, METADATA_AUTHORS, METADATA_CONTACT,
+    EVIDENCE, IDENTIFIER, METADATA_AUTHORS, METADATA_CONTACT,
     METADATA_COPYRIGHT, METADATA_DESCRIPTION, METADATA_DISCLAIMER, METADATA_LICENSES, METADATA_NAME, METADATA_VERSION,
-    MODIFIER, NAME, NAMESPACE, OBJECT, PARTNER_3P, PARTNER_5P, PMOD, RANGE_3P, RANGE_5P, SUBJECT,
+    NAME, NAMESPACE,
 )
-from ..dsl import EnumeratedFusionRange, Fragment, GeneModification, Hgvs, MissingFusionRange, ProteinModification
 from ..io.gpickle import from_bytes, to_bytes
 from ..tokens import parse_result_to_dsl
 
@@ -31,14 +29,11 @@ __all__ = [
     'NamespaceEntry',
     'Network',
     'Node',
-    'Modification',
     'Author',
     'Citation',
     'Evidence',
     'Edge',
-    'Property',
     'edge_annotation',
-    'edge_property',
     'network_edge',
     'network_node',
 ]
@@ -47,14 +42,9 @@ NAME_TABLE_NAME = 'pybel_name'
 NAMESPACE_TABLE_NAME = 'pybel_namespace'
 
 NODE_TABLE_NAME = 'pybel_node'
-MODIFICATION_TABLE_NAME = 'pybel_modification'
-NODE_MODIFICATION_TABLE_NAME = 'pybel_node_modification'
-
-PROPERTY_TABLE_NAME = 'pybel_property'
 
 EDGE_TABLE_NAME = 'pybel_edge'
 EDGE_ANNOTATION_TABLE_NAME = 'pybel_edge_name'
-EDGE_PROPERTY_TABLE_NAME = 'pybel_edge_property'
 
 AUTHOR_TABLE_NAME = 'pybel_author'
 CITATION_TABLE_NAME = 'pybel_citation'
@@ -82,20 +72,20 @@ class Namespace(Base):
     uploaded = Column(DateTime, nullable=False, default=datetime.datetime.utcnow, doc='The date of upload')
 
     # logically the "namespace"
-    keyword = Column(
+    keyword = Column(
         String(255), nullable=True, index=True,
-        doc='Keyword that is used in a BEL file to identify a specific namespace',
+        doc='Keyword that is used in a BEL file to identify a specific namespace',
     )
 
     # A namespace either needs a URL or a pattern
-    pattern = Column(
+    pattern = Column(
         String(255), nullable=True, index=True,
-        doc="Contains regex pattern for value identification.",
+        doc="Contains regex pattern for value identification.",
     )
 
-    miriam_id = Column(
+    miriam_id = Column(
         String(16), nullable=True,
-        doc=r'MIRIAM resource identifier matching the regular expression ``^MIR:001\d{5}$``',
+        doc=r'MIRIAM resource identifier matching the regular expression ``^MIR:001\d{5}$``',
     )
     miriam_name = Column(String(255), nullable=True)
     miriam_namespace = Column(String(255), nullable=True)
@@ -164,9 +154,9 @@ class NamespaceEntry(Base):
     __tablename__ = NAME_TABLE_NAME
     id = Column(Integer, primary_key=True)
 
-    name = Column(
+    name = Column(
         String(1023), index=True, nullable=False,
-        doc='Name that is defined in the corresponding namespace definition file',
+        doc='Name that is defined in the corresponding namespace definition file',
     )
     identifier = Column(String(255), index=True, nullable=True, doc='The database accession number')
     encoding = Column(String(8), nullable=True, doc='The biological entity types for which this name is valid')
@@ -318,112 +308,6 @@ class Network(Base):
         self.blob = to_bytes(graph)
 
 
-node_modification = Table(
-    NODE_MODIFICATION_TABLE_NAME, Base.metadata,
-    Column('node_id', Integer, ForeignKey('{}.id'.format(NODE_TABLE_NAME)), primary_key=True),
-    Column('modification_id', Integer, ForeignKey('{}.id'.format(MODIFICATION_TABLE_NAME)), primary_key=True),
-)
-
-
-class Modification(Base):
-    """The modifications that are present in the network are stored in this table."""
-
-    __tablename__ = MODIFICATION_TABLE_NAME
-    id = Column(Integer, primary_key=True)
-
-    type = Column(String(255), nullable=False, doc='Type of the stored modification e.g. Fusion, gmod, pmod, etc')
-
-    variantString = Column(String(255), nullable=True, doc='HGVS string if sequence modification')  # noqa: N815
-
-    p3_partner_id = Column(Integer, ForeignKey('{}.id'.format(NAME_TABLE_NAME)), nullable=True)
-    p3_partner = relationship(NamespaceEntry, foreign_keys=[p3_partner_id])
-
-    p3_reference = Column(String(10), nullable=True)
-    p3_start = Column(String(255), nullable=True)
-    p3_stop = Column(String(255), nullable=True)
-
-    p5_partner_id = Column(Integer, ForeignKey('{}.id'.format(NAME_TABLE_NAME)), nullable=True)
-    p5_partner = relationship(NamespaceEntry, foreign_keys=[p5_partner_id])
-
-    p5_reference = Column(String(10), nullable=True)
-    p5_start = Column(String(255), nullable=True)
-    p5_stop = Column(String(255), nullable=True)
-
-    identifier_id = Column(Integer, ForeignKey('{}.id'.format(NAME_TABLE_NAME)), nullable=True)
-    identifier = relationship(NamespaceEntry, foreign_keys=[identifier_id])
-
-    residue = Column(String(3), nullable=True, doc='Three letter amino acid code if PMOD')
-    position = Column(Integer, nullable=True, doc='Position of PMOD or GMOD')
-
-    md5 = Column(String(255), index=True)
-
-    def _fusion_to_json(self) -> Mapping[str, Any]:
-        """Convert this modification to a FUSION data dictionary.
-
-        Don't use this without checking ``self.type == FUSION`` first.
-        """
-        if self.p5_reference:
-            range_5p = EnumeratedFusionRange(
-                reference=str(self.p5_reference),
-                start=int_or_str(self.p5_start),
-                stop=int_or_str(self.p5_stop),
-            )
-        else:
-            range_5p = MissingFusionRange()
-
-        if self.p3_reference:
-            range_3p = EnumeratedFusionRange(
-                reference=str(self.p3_reference),
-                start=int_or_str(self.p3_start),
-                stop=int_or_str(self.p3_stop),
-            )
-        else:
-            range_3p = MissingFusionRange()
-
-        return {
-            PARTNER_5P: self.p5_partner.to_json(),  # just the identifier pair
-            PARTNER_3P: self.p3_partner.to_json(),  # just the identifier pair
-            RANGE_5P: range_5p,
-            RANGE_3P: range_3p,
-        }
-
-    def to_json(self):
-        """Recreate a is_variant dictionary for :class:`BELGraph`.
-
-        :return: Dictionary that describes a variant or a fusion.
-        :rtype: Variant or FusionBase
-        """
-        if self.type == FUSION:
-            return self._fusion_to_json()
-
-        if self.type == FRAGMENT:
-            return Fragment(
-                start=int_or_str(self.p3_start),
-                stop=int_or_str(self.p3_stop),
-            )
-
-        if self.type == HGVS:
-            return Hgvs(str(self.variantString))
-
-        if self.type == GMOD:
-            return GeneModification(
-                namespace=self.identifier.namespace.keyword,
-                name=self.identifier.name,
-                identifier=self.identifier.identifier,
-            )
-
-        if self.type == PMOD:
-            return ProteinModification(
-                namespace=self.identifier.namespace.keyword,
-                name=self.identifier.name,
-                identifier=self.identifier.identifier,
-                code=self.residue,
-                position=self.position,
-            )
-
-        raise TypeError('unhandled type ({}) for modification {}'.format(self.type, self))
-
-
 class Node(Base):
     """Represents a BEL Term."""
 
@@ -431,20 +315,14 @@ class Node(Base):
     id = Column(Integer, primary_key=True)
 
     type = Column(String(255), nullable=False, doc='The type of the represented biological entity e.g. Protein or Gene')
-    is_variant = Column(Boolean, default=False, doc='Identifies weather or not the given node is a variant')
-    has_fusion = Column(Boolean, default=False, doc='Identifies weather or not the given node is a fusion')
     bel = Column(String(255), nullable=False, doc='Canonical BEL term that represents the given node')
-    md5 = Column(String(255), nullable=True, index=True)
+    md5 = Column(String(255), nullable=False, index=True)
 
     namespace_entry_id = Column(Integer, ForeignKey('{}.id'.format(NAME_TABLE_NAME)), nullable=True)
-    namespace_entry = relationship(NamespaceEntry, foreign_keys=[namespace_entry_id])
-
-    modifications = relationship(
-        Modification, secondary=node_modification, lazy='dynamic',
-        backref=backref('nodes', lazy='dynamic'),
-    )
+    namespace_entry = relationship(NamespaceEntry, foreign_keys=[namespace_entry_id],
+                                   backref=backref('nodes', lazy='dynamic'))
 
-    data = Column(Text, nullable=False, doc='PyBEL BaseEntity as JSON')
+    data = Column(JSON, nullable=False, doc='PyBEL BaseEntity as JSON')
 
     @staticmethod
     def _start_from_base_entity(base_entity) -> 'Node':
@@ -456,7 +334,7 @@ class Node(Base):
             type=base_entity.function,
             bel=base_entity.as_bel(),
             md5=base_entity.md5,
-            data=json.dumps(base_entity),
+            data=base_entity,
         )
 
     @classmethod
@@ -481,7 +359,7 @@ class Node(Base):
 
         :rtype: pybel.dsl.BaseEntity
         """
-        return parse_result_to_dsl(json.loads(self.data))
+        return parse_result_to_dsl(self.data)
 
     def to_json(self):
         """Serialize this node as a JSON object using as_bel()."""
@@ -645,64 +523,6 @@ edge_annotation = Table(
     Column('name_id', Integer, ForeignKey('{}.id'.format(NAME_TABLE_NAME)), primary_key=True),
 )
 
-edge_property = Table(
-    EDGE_PROPERTY_TABLE_NAME, Base.metadata,
-    Column('edge_id', Integer, ForeignKey('{}.id'.format(EDGE_TABLE_NAME)), primary_key=True),
-    Column('property_id', Integer, ForeignKey('{}.id'.format(PROPERTY_TABLE_NAME)), primary_key=True),
-)
-
-
-class Property(Base):
-    """The property table contains additional information that is used to describe the context of a relation."""
-
-    __tablename__ = PROPERTY_TABLE_NAME
-
-    id = Column(Integer, primary_key=True)
-
-    is_subject = Column(Boolean, doc='Identifies which participant of the edge if affected by the given property')
-    modifier = Column(String(255), doc='The modifier: one of activity, degradation, location, or translocation')
-
-    relative_key = Column(String(255), nullable=True, doc='Relative key of effect e.g. to_tloc or from_tloc')
-
-    md5 = Column(String(255), index=True)
-
-    effect_id = Column(Integer, ForeignKey('{}.id'.format(NAME_TABLE_NAME)), nullable=True)
-    effect = relationship(NamespaceEntry)
-
-    @property
-    def side(self) -> str:
-        """Return either :data:`pybel.constants.SUBJECT` or :data:`pybel.constants.OBJECT`."""
-        return SUBJECT if self.is_subject else OBJECT
-
-    def to_json(self):
-        """Create a property dict that is used to recreate an edge dictionary for a :class:`BELGraph`.
-
-        :return: Property dictionary of an edge that is participant (sub/obj) related.
-        :rtype: dict
-        """
-        participant = self.side
-
-        prop_dict = {
-            participant: {
-                MODIFIER: self.modifier,  # FIXME this is probably wrong for location
-            },
-        }
-
-        if self.modifier == LOCATION:
-            prop_dict[participant] = {
-                LOCATION: self.effect.to_json(),
-            }
-        if self.relative_key:  # for translocations
-            prop_dict[participant][EFFECT] = {
-                self.relative_key: self.effect.to_json(),
-            }
-        elif self.effect:  # for activities
-            prop_dict[participant][EFFECT] = self.effect.to_json()
-
-        # degradations don't have modifications
-
-        return prop_dict
-
 
 class Edge(Base):
     """Relationships between BEL nodes and their properties, annotations, and provenance."""
@@ -715,29 +535,33 @@ class Edge(Base):
     relation = Column(String(255), nullable=False)
 
     source_id = Column(Integer, ForeignKey('{}.id'.format(NODE_TABLE_NAME)), nullable=False)
-    source = relationship(
+    source = relationship(
         Node, foreign_keys=[source_id],
-        backref=backref('out_edges', lazy='dynamic', cascade='all, delete-orphan'),
+        backref=backref('out_edges', lazy='dynamic', cascade='all, delete-orphan'),
     )
 
     target_id = Column(Integer, ForeignKey('{}.id'.format(NODE_TABLE_NAME)), nullable=False)
-    target = relationship(
+    target = relationship(
         Node, foreign_keys=[target_id],
-        backref=backref('in_edges', lazy='dynamic', cascade='all, delete-orphan'),
+        backref=backref('in_edges', lazy='dynamic', cascade='all, delete-orphan'),
     )
 
     evidence_id = Column(Integer, ForeignKey('{}.id'.format(EVIDENCE_TABLE_NAME)), nullable=True)
     evidence = relationship(Evidence, backref=backref('edges', lazy='dynamic'))
 
-    annotations = relationship(
+    annotations = relationship(
         NamespaceEntry, secondary=edge_annotation, lazy="dynamic",
-        backref=backref('edges', lazy='dynamic'),
+        backref=backref('edges', lazy='dynamic'),
     )
-    properties = relationship(Property, secondary=edge_property, lazy="dynamic")  # , cascade='all, delete-orphan')
+
+    # free_annotations = Column(JSON, nullable=True, doc='Ungrounded extra annotations')
+
+    source_modifier = Column(JSON, nullable=True, doc='Modifiers for the source of the edge')
+    target_modifier = Column(JSON, nullable=True, doc='Modifiers for the target of the edge')
 
     md5 = Column(String(255), index=True, doc='The hash of the source, target, and associated metadata')
 
-    data = Column(Text, nullable=False, doc='The stringified JSON representing this edge')
+    data = Column(JSON, nullable=False, doc='The stringified JSON representing this edge')
 
     def __str__(self):
         return self.bel
@@ -773,7 +597,7 @@ class Edge(Base):
             'source': source_dict,
             'target': target_dict,
             'key': self.md5,
-            'data': json.loads(self.data),
+            'data': self.data,
         }
 
         if include_id:
@@ -790,6 +614,6 @@ class Edge(Base):
         v = self.target.as_bel()
 
         if self.evidence:
-            return graph.add_qualified_edge(u, v, **json.loads(self.data))
+            return graph.add_qualified_edge(u, v, **self.data)
         else:
             return graph.add_unqualified_edge(u, v, self.relation)
diff --git a/src/pybel/struct/graph.py b/src/pybel/struct/graph.py
index 7551e7b2..01d17579 100644
--- a/src/pybel/struct/graph.py
+++ b/src/pybel/struct/graph.py
@@ -14,18 +14,20 @@ import networkx as nx
 from .operations import left_full_join, left_node_intersection_join, left_outer_join
 from ..canonicalize import edge_to_bel
 from ..constants import (
-    ANNOTATIONS, ASSOCIATION, CAUSES_NO_CHANGE, CITATION, CITATION_AUTHORS, CITATION_DB, CITATION_IDENTIFIER,
-    CITATION_TYPE_PUBMED, CONCEPT, CORRELATION, DECREASES, DIRECTLY_DECREASES, DIRECTLY_INCREASES,
-    EQUIVALENT_TO, EVIDENCE, GRAPH_ANNOTATION_LIST, GRAPH_ANNOTATION_PATTERN, GRAPH_ANNOTATION_URL,
+    ACTIVITY, ANNOTATIONS, ASSOCIATION, CAUSES_NO_CHANGE, CITATION, CITATION_AUTHORS, CITATION_DB, CITATION_IDENTIFIER,
+    CITATION_TYPE_PUBMED, CORRELATION, DECREASES, DEGRADATION, DIRECTLY_DECREASES, DIRECTLY_INCREASES, EFFECT,
+    EQUIVALENT_TO, EVIDENCE, FROM_LOC, GRAPH_ANNOTATION_LIST, GRAPH_ANNOTATION_PATTERN, GRAPH_ANNOTATION_URL,
     GRAPH_METADATA, GRAPH_NAMESPACE_PATTERN, GRAPH_NAMESPACE_URL, GRAPH_PATH, GRAPH_PYBEL_VERSION, 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,
-    NEGATIVE_CORRELATION, NO_CORRELATION, OBJECT, ORTHOLOGOUS, PART_OF, POSITIVE_CORRELATION, PRODUCTS, REACTANTS,
-    REGULATES, RELATION, SUBJECT, TRANSCRIBED_TO, TRANSLATED_TO, VARIANTS,
+    HAS_REACTANT, HAS_VARIANT, INCREASES, IS_A, LOCATION, METADATA_AUTHORS, METADATA_CONTACT, METADATA_COPYRIGHT,
+    METADATA_DESCRIPTION, METADATA_DISCLAIMER, METADATA_LICENSES, METADATA_NAME, METADATA_VERSION, MODIFIER,
+    NEGATIVE_CORRELATION, NO_CORRELATION, OBJECT, ORTHOLOGOUS, PART_OF, POSITIVE_CORRELATION, REGULATES, RELATION,
+    SUBJECT, TO_LOC, TRANSCRIBED_TO, TRANSLATED_TO, TRANSLOCATION,
 )
 from ..dsl import (
-    BaseAbundance, BaseEntity, ComplexAbundance, Gene, MicroRna, Protein, ProteinModification, Reaction, Rna, activity,
+    BaseAbundance, BaseConcept, BaseEntity, CentralDogma, ComplexAbundance, Gene, ListAbundance, MicroRna, Protein,
+    ProteinModification, Reaction, Rna, activity,
 )
+from ..language import Entity
 from ..parser.exc import BELParserWarning
 from ..typing import EdgeData
 from ..utils import CitationDict, citation_dict, hash_edge
@@ -355,7 +357,7 @@ class BELGraph(nx.MultiDiGraph):
 
         return self._help_add_edge_helper(u, v, attr)
 
-    def _help_add_edge_helper(self, u: BaseEntity, v: BaseEntity, attr: Mapping) -> str:
+    def _help_add_edge_helper(self, u: BaseEntity, v: BaseEntity, attr: Mapping[str, Any]) -> str:
         key = hash_edge(u, v, attr)
 
         if not self.has_edge(u, v, key):
@@ -430,8 +432,8 @@ class BELGraph(nx.MultiDiGraph):
         evidence: str,
         citation: Union[str, Tuple[str, str], CitationDict],
         annotations: Optional[AnnotationsHint] = None,
-        subject_modifier: Optional[Mapping] = None,
-        object_modifier: Optional[Mapping] = None,
+        subject_modifier: Optional[Mapping[str, Any]] = None,
+        object_modifier: Optional[Mapping[str, Any]] = None,
         **attr
     ) -> str:
         """Add a qualified edge.
@@ -468,8 +470,8 @@ class BELGraph(nx.MultiDiGraph):
         evidence: str,
         citation: Union[str, Tuple[str, str], CitationDict],
         annotations: Optional[AnnotationsHint] = None,
-        subject_modifier: Optional[Mapping] = None,
-        object_modifier: Optional[Mapping] = None,
+        subject_modifier: Optional[Mapping[str, Any]] = None,
+        object_modifier: Optional[Mapping[str, Any]] = None,
         **attr
     ):
         attr.update({
@@ -482,10 +484,10 @@ class BELGraph(nx.MultiDiGraph):
             attr[ANNOTATIONS] = _clean_annotations(annotations)
 
         if subject_modifier:
-            attr[SUBJECT] = subject_modifier
+            attr[SUBJECT] = _handle_modifier(subject_modifier)
 
         if object_modifier:
-            attr[OBJECT] = object_modifier
+            attr[OBJECT] = _handle_modifier(object_modifier)
 
         return attr
 
@@ -611,18 +613,18 @@ class BELGraph(nx.MultiDiGraph):
 
         self.add_node(node)
 
-        if VARIANTS in node:
+        if isinstance(node, CentralDogma) and node.variants:
             self.add_has_variant(node.get_parent(), node)
 
-        elif MEMBERS in node:
-            for member in node[MEMBERS]:
+        elif isinstance(node, ListAbundance):
+            for member in node.members:
                 self.add_part_of(member, node)
 
-        elif PRODUCTS in node and REACTANTS in node:
-            for reactant_tokens in node[REACTANTS]:
-                self.add_has_reactant(node, reactant_tokens)
-            for product_tokens in node[PRODUCTS]:
-                self.add_has_product(node, product_tokens)
+        elif isinstance(node, Reaction):
+            for reactant in node.reactants:
+                self.add_has_reactant(node, reactant)
+            for product in node.products:
+                self.add_has_product(node, product)
 
     def add_reaction(
         self,
@@ -769,7 +771,7 @@ class BELGraph(nx.MultiDiGraph):
         v: BaseEntity,
         edge_data: EdgeData,
         sep: Optional[str] = None,
-        use_identifiers: bool = False,
+        use_identifiers: bool = True,
     ) -> str:
         """Serialize a pair of nodes and related edge data as a BEL relation."""
         return edge_to_bel(u, v, data=edge_data, sep=sep, use_identifiers=use_identifiers)
@@ -811,7 +813,7 @@ class BELGraph(nx.MultiDiGraph):
 
         Might have cross references in future.
         """
-        return CONCEPT in node and node[CONCEPT][NAMESPACE] == namespace
+        return isinstance(node, BaseConcept) and node.namespace.lower() == namespace.lower()
 
     def node_has_namespace(self, node: BaseEntity, namespace: str) -> bool:
         """Check if the node have the given namespace.
@@ -864,6 +866,27 @@ def _clean_annotations(annotations_dict: AnnotationsHint) -> AnnotationsDict:
     }
 
 
+def _handle_modifier(side_data: Dict[str, Any]) -> Mapping[str, Any]:
+    modifier = side_data.get(MODIFIER)
+    effect = side_data.get(EFFECT)
+
+    if modifier == ACTIVITY:
+        if effect is not None:
+            side_data[EFFECT] = Entity(**effect)
+    elif modifier == TRANSLOCATION:
+        if effect is not None:
+            effect[FROM_LOC] = Entity(**effect[FROM_LOC])
+            effect[TO_LOC] = Entity(**effect[TO_LOC])
+    elif modifier == DEGRADATION or modifier is None:
+        pass
+    else:
+        raise ValueError('invalid modifier: {}'.format(modifier))
+
+    if LOCATION in side_data:
+        side_data[LOCATION] = Entity(**side_data[LOCATION])
+    return side_data
+
+
 def _handle_citation(citation: Union[str, Tuple[str, str], CitationDict]) -> CitationDict:
     if isinstance(citation, str):
         return citation_dict(db=CITATION_TYPE_PUBMED, db_id=citation)
diff --git a/src/pybel/struct/mutation/collapse/collapse.py b/src/pybel/struct/mutation/collapse/collapse.py
index 9d5d3b75..ea0b9bc2 100644
--- a/src/pybel/struct/mutation/collapse/collapse.py
+++ b/src/pybel/struct/mutation/collapse/collapse.py
@@ -10,6 +10,7 @@ from ...filters.edge_predicate_builders import build_relation_predicate
 from ...pipeline import in_place_transformation
 from ....constants import HAS_VARIANT
 from ....dsl import BaseEntity
+from ....utils import hash_edge
 
 __all__ = [
     'collapse_pair',
@@ -40,18 +41,19 @@ def collapse_pair(graph, survivor: BaseEntity, victim: BaseEntity) -> None:
     :param victim: The BEL node to collapse into the surviving node
     """
     graph.add_edges_from(
-        (survivor, successor, key, edge_data)
-        for _, successor, key, edge_data in graph.out_edges(victim, keys=True, data=True)
+        (survivor, successor, hash_edge(survivor, successor, edge_data), edge_data)
+        for _, successor, edge_data in graph.out_edges(victim, data=True)
         if successor != survivor
-    )  # FIXME key should be updated
+    )
 
     graph.add_edges_from(
-        (predecessor, survivor, key, data)
-        for predecessor, _, key, data in graph.in_edges(victim, keys=True, data=True)
+        (predecessor, survivor, hash_edge(predecessor, survivor, edge_data), edge_data)
+        for predecessor, _, edge_data in graph.in_edges(victim, data=True)
         if predecessor != survivor
-    )  # FIXME key should be updated
+    )
 
-    graph.remove_node(victim)
+    if victim in graph:
+        graph.remove_node(victim)
 
 
 # TODO what happens when collapsing is not consistent? Need to build intermediate mappings and test their consistency.
diff --git a/src/pybel/struct/summary/node_summary.py b/src/pybel/struct/summary/node_summary.py
index 30bbac13..f96972e4 100644
--- a/src/pybel/struct/summary/node_summary.py
+++ b/src/pybel/struct/summary/node_summary.py
@@ -12,7 +12,9 @@ from ...constants import (
     ACTIVITY, CONCEPT, EFFECT, FROM_LOC, FUSION, KIND, LOCATION, MEMBERS, MODIFIER, NAME, NAMESPACE, OBJECT, PARTNER_3P,
     PARTNER_5P, SUBJECT, TO_LOC, TRANSLOCATION, VARIANTS,
 )
-from ...dsl import BaseEntity, Pathology
+from ...dsl import BaseConcept, BaseEntity, CentralDogma, FusionBase, ListAbundance, Pathology, Reaction
+from ...dsl.node_classes import EntityVariant
+from ...language import Entity
 
 __all__ = [
     'get_functions',
@@ -27,6 +29,9 @@ __all__ = [
     'count_pathologies',
     'get_top_pathologies',
     'get_top_hubs',
+    'iterate_node_entities',
+    'node_is_grounded',
+    'get_ungrounded_nodes',
 ]
 
 
@@ -57,11 +62,34 @@ def count_functions(graph) -> typing.Counter[str]:
 
 
 def _iterate_namespaces(graph) -> Iterable[str]:
-    return (
-        node[CONCEPT][NAMESPACE]
-        for node in graph
-        if CONCEPT in node
-    )
+    for entity in itt.chain(iterate_entities(graph), _iterate_edge_entities(graph)):
+        yield entity.namespace
+
+
+def _iterate_edge_entities(graph) -> Iterable[Entity]:
+    for ((_, _, data), side) in itt.product(graph.edges(data=True), (SUBJECT, OBJECT)):
+        side_data = data.get(side)
+        if side_data is None:
+            continue
+
+        modifier = side_data.get(MODIFIER)
+        effect = side_data.get(EFFECT)
+
+        if modifier == ACTIVITY and effect is not None:
+            assert isinstance(effect, Entity)
+            yield effect
+        elif modifier == TRANSLOCATION and effect is not None:
+            from_loc = effect[FROM_LOC]
+            assert isinstance(from_loc, Entity)
+            yield from_loc
+            to_loc = effect[TO_LOC]
+            assert isinstance(to_loc, Entity)
+            yield to_loc
+
+        location = side_data.get(LOCATION)
+        if location is not None:
+            assert isinstance(location, Entity)
+            yield location
 
 
 def count_namespaces(graph) -> typing.Counter[str]:
@@ -69,7 +97,6 @@ def count_namespaces(graph) -> typing.Counter[str]:
 
     :param pybel.BELGraph graph: A BEL graph
     :return: A Counter from {namespace: frequency}
-    :rtype: collections.Counter
     """
     return Counter(_iterate_namespaces(graph))
 
@@ -96,7 +123,6 @@ def get_names(graph) -> Mapping[str, Set[str]]:
     """Get all names for each namespace.
 
     :type graph: pybel.BELGraph
-    :rtype: dict[str,set[str]]
     """
     rv = defaultdict(set)
     for namespace, name in _identifier_filtered_iterator(graph):
@@ -104,6 +130,46 @@ def get_names(graph) -> Mapping[str, Set[str]]:
     return dict(rv)
 
 
+def get_entities(graph) -> Set[Entity]:
+    return set(iterate_entities(graph))
+
+
+def iterate_entities(graph) -> Iterable[Entity]:
+    for node in graph:
+        yield from iterate_node_entities(node)
+
+
+def node_is_grounded(node: BaseEntity) -> bool:
+    """Check if a node is grounded."""
+    return all(
+        entity.identifier is not None and entity.name is not None
+        for entity in iterate_node_entities(node)
+    )
+
+
+def iterate_node_entities(node) -> Iterable[Entity]:
+    """Iterate over all named entites that comprise a node.
+
+    This includes the node's name, the members/reactants/products of the node,
+    the fusion partners, the named variants, and all recursive ones too.
+    """
+    if isinstance(node, BaseConcept):
+        yield node.entity
+    if isinstance(node, ListAbundance):
+        for member in node.members:
+            yield from iterate_node_entities(member)
+    if isinstance(node, Reaction):
+        for member in itt.chain(node.reactants, node.products):
+            yield from iterate_node_entities(member)
+    if isinstance(node, CentralDogma):
+        for variant in node.variants or []:
+            if isinstance(variant, EntityVariant):
+                yield variant.entity
+    if isinstance(node, FusionBase):
+        yield from iterate_node_entities(node.partner_5p)
+        yield from iterate_node_entities(node.partner_3p)
+
+
 def _identifier_filtered_iterator(graph) -> Iterable[Tuple[str, str]]:
     """Iterate over names in the given namespace."""
     for data in graph:
@@ -236,3 +302,12 @@ def get_top_pathologies(graph, n: Optional[int] = 15) -> List[Tuple[BaseEntity,
     :param n: The number of top connected pathologies to return. If None, returns all nodes
     """
     return count_pathologies(graph).most_common(n)
+
+
+def get_ungrounded_nodes(graph) -> Set[BaseEntity]:
+    """Get all ungrounded nodes in the graph."""
+    return {
+        node
+        for node in graph
+        if not node_is_grounded(node)
+    }


Test patch:
diff --git a/tests/test_manager/test_manager_graph.py b/tests/test_manager/test_manager_graph.py
index aac28660..c74095dd 100644
--- a/tests/test_manager/test_manager_graph.py
+++ b/tests/test_manager/test_manager_graph.py
@@ -7,13 +7,10 @@ import unittest
 from collections import Counter
 from random import randint
 
-from sqlalchemy import not_
-
 from pybel import BELGraph, from_bel_script, from_database, to_database
 from pybel.constants import (
-    ABUNDANCE, BEL_DEFAULT_NAMESPACE, BIOPROCESS, CITATION_AUTHORS, CITATION_DATE, CITATION_DB, CITATION_IDENTIFIER,
-    CITATION_JOURNAL, CITATION_TYPE_OTHER, CITATION_TYPE_PUBMED, DECREASES, HAS_PRODUCT, HAS_REACTANT, INCREASES,
-    METADATA_NAME, METADATA_VERSION, MIRNA, PART_OF, PATHOLOGY, PROTEIN, RELATION, CITATION_DB_NAME
+    ABUNDANCE, BIOPROCESS, CITATION_DB, CITATION_IDENTIFIER, CITATION_TYPE_PUBMED, DECREASES,
+    HAS_PRODUCT, HAS_REACTANT, INCREASES, METADATA_NAME, METADATA_VERSION, MIRNA, PART_OF, PATHOLOGY, PROTEIN, RELATION,
 )
 from pybel.dsl import (
     BaseEntity, ComplexAbundance, CompositeAbundance, EnumeratedFusionRange, Fragment, Gene, GeneFusion,
@@ -24,7 +21,7 @@ from pybel.dsl.namespaces import chebi, hgnc, mirbase
 from pybel.examples import ras_tloc_graph, sialic_acid_graph
 from pybel.language import Entity
 from pybel.manager import models
-from pybel.manager.models import Author, Citation, Edge, Evidence, NamespaceEntry, Node, Property
+from pybel.manager.models import Author, Citation, Edge, Evidence, NamespaceEntry, Node
 from pybel.testing.cases import FleetingTemporaryCacheMixin, TemporaryCacheClsMixin, TemporaryCacheMixin
 from pybel.testing.constants import test_bel_simple
 from pybel.testing.mocks import mock_bel_resources
@@ -807,8 +804,8 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         self.assertEqual(2, network.nodes.count(), msg='Missing one or both of the nodes.')
         self.assertEqual(1, network.edges.count(), msg='Missing the edge')
 
-        edge = network.edges.first()
-        self.assertEqual(2, edge.properties.count())
+        #edge = network.edges.first()
+        #self.assertEqual(2, edge.properties.count())
 
     @mock_bel_resources
     def test_subject_translocation_custom_to_loc(self, mock):
@@ -830,7 +827,7 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         self.assertEqual(1, network.edges.count())
 
         edge = network.edges.first()
-        self.assertEqual(2, edge.properties.count())
+        # self.assertEqual(2, edge.properties.count())
 
     @mock_bel_resources
     def test_subject_activity_default(self, mock):
@@ -857,9 +854,6 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         kin = list(kin_list)[0]
         self.assertEqual('kin', kin.name)
 
-        effects = self.manager.session.query(Property).join(NamespaceEntry).filter(Property.effect == kin)
-        self.assertEqual(1, effects.count(), msg='number of effects')
-
     @mock_bel_resources
     def test_subject_activity_custom(self, mock):
         p1_name = n()
@@ -887,9 +881,6 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         kin = list(kin_list)[0]
         self.assertEqual(dummy_activity_name, kin.name)
 
-        effects = self.manager.session.query(Property).join(NamespaceEntry).filter(Property.effect == kin)
-        self.assertEqual(1, effects.count())
-
     @mock_bel_resources
     def test_object_activity_default(self, mock):
         p1_name = n()
@@ -915,9 +906,6 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         kin = list(kin_list)[0]
         self.assertEqual('kin', kin.name)
 
-        effects = self.manager.session.query(Property).join(NamespaceEntry).filter(Property.effect == kin)
-        self.assertEqual(1, effects.count())
-
     @mock_bel_resources
     def test_object_activity_custom(self, mock):
         p1_name = n()
@@ -945,9 +933,6 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         kin = list(kin_list)[0]
         self.assertEqual(dummy_activity_name, kin.name)
 
-        effects = self.manager.session.query(Property).join(NamespaceEntry).filter(Property.effect == kin)
-        self.assertEqual(1, effects.count())
-
     def test_subject_degradation(self):
         self.graph.add_increases(
             Protein(name='YFG', namespace='HGNC'),
@@ -964,7 +949,7 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         self.assertEqual(1, network.edges.count())
 
         edge = network.edges.first()
-        self.assertEqual(1, edge.properties.count())
+        # self.assertEqual(1, edge.properties.count())
 
     def test_object_degradation(self):
         self.graph.add_increases(
@@ -982,7 +967,7 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         self.assertEqual(1, network.edges.count())
 
         edge = network.edges.first()
-        self.assertEqual(1, edge.properties.count())
+        # self.assertEqual(1, edge.properties.count())
 
     def test_subject_location(self):
         self.graph.add_increases(
@@ -1000,7 +985,7 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         self.assertEqual(1, network.edges.count())
 
         edge = network.edges.first()
-        self.assertEqual(1, edge.properties.count())
+        # self.assertEqual(1, edge.properties.count())
 
     def test_mixed_1(self):
         """Test mixed having location and something else."""
@@ -1028,19 +1013,8 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         self.assertEqual(1, network.edges.count())
 
         edge = network.edges.first()
-        self.assertEqual(2, edge.properties.count())
-
-        subject = edge.properties.filter(Property.is_subject).one()
-        self.assertTrue(subject.is_subject)
-        self.assertEqual('gtp', subject.effect.name)
-        self.assertIsNotNone(subject.effect.namespace)
-        self.assertEqual(BEL_DEFAULT_NAMESPACE, subject.effect.namespace.keyword)
-
-        object = edge.properties.filter(not_(Property.is_subject)).one()
-        self.assertFalse(object.is_subject)
-        self.assertEqual('kin', object.effect.name)
-        self.assertIsNotNone(object.effect.namespace)
-        self.assertEqual(BEL_DEFAULT_NAMESPACE, object.effect.namespace.keyword)
+        # self.assertEqual(2, edge.properties.count())
+        # FIXME
 
     def test_mixed_2(self):
         """Tests both subject and object activity with location information as well."""
@@ -1064,9 +1038,9 @@ class TestReconstituteEdges(TemporaryCacheMixin):
         self.assertEqual(1, network.edges.count())
 
         edge = network.edges.first()
-        self.assertEqual(4, edge.properties.count())
-        self.assertEqual(2, edge.properties.filter(Property.is_subject).count())
-        self.assertEqual(2, edge.properties.filter(not_(Property.is_subject)).count())
+        # self.assertEqual(4, edge.properties.count())
+        # self.assertEqual(2, edge.properties.filter(Property.is_subject).count())
+        # self.assertEqual(2, edge.properties.filter(not_(Property.is_subject)).count())
 
 
 class TestNoAddNode(TemporaryCacheMixin):
@@ -1167,3 +1141,7 @@ class TestEquivalentNodes(unittest.TestCase):
         self.assertTrue(graph.node_has_namespace(b, 'HGNC'))
         self.assertTrue(graph.node_has_namespace(c, 'HGNC'))
         self.assertTrue(graph.node_has_namespace(d, 'HGNC'))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_struct/test_summary/test_summary_nodes.py b/tests/test_struct/test_summary/test_summary_nodes.py
index 0340d5e4..f59b5f55 100644
--- a/tests/test_struct/test_summary/test_summary_nodes.py
+++ b/tests/test_struct/test_summary/test_summary_nodes.py
@@ -11,7 +11,7 @@ from pybel.dsl import fusion_range, pathology, protein, protein_fusion
 from pybel.examples import egf_graph, sialic_acid_graph
 from pybel.struct.summary.node_summary import (
     count_functions, count_names_by_namespace, count_namespaces, count_pathologies, count_variants, get_functions,
-    get_names_by_namespace, get_namespaces, get_top_hubs, get_top_pathologies,
+    get_names, get_names_by_namespace, get_namespaces, get_top_hubs, get_top_pathologies,
 )
 from pybel.testing.utils import n
 
@@ -24,7 +24,7 @@ class TestSummary(unittest.TestCase):
         result = {
             PROTEIN: 7,
             COMPLEX: 1,
-            ABUNDANCE: 1
+            ABUNDANCE: 1,
         }
 
         self.assertEqual(set(result), get_functions(sialic_acid_graph))
@@ -35,35 +35,23 @@ class TestSummary(unittest.TestCase):
         result = {
             PROTEIN: 10,
             COMPLEX: 1,
-            BIOPROCESS: 1
+            BIOPROCESS: 1,
         }
 
         self.assertEqual(set(result), get_functions(egf_graph))
         self.assertEqual(result, count_functions(egf_graph))
 
-    def test_namespaces_sialic(self):
+    def test_summarize_sialic(self):
         """Test getting and counting namespaces' contents on the sialic acid graph."""
-        result = {
-            'HGNC': 7,
-            'CHEBI': 1,
-        }
-
-        self.assertEqual(set(result), get_namespaces(sialic_acid_graph))
-        self.assertEqual(Counter(result), count_namespaces(sialic_acid_graph))
-
-    def test_namespaces_egf(self):
-        """Test getting and counting namespaces' contents on the EGF graph."""
-        result = {
-            'HGNC': 10,
-            'GO': 1,
+        namespace_result = {
+            'hgnc': 8,
+            'chebi': 2,
+            'bel': 3,
         }
+        self.assertEqual(set(namespace_result), get_namespaces(sialic_acid_graph))
+        self.assertEqual(Counter(namespace_result), count_namespaces(sialic_acid_graph))
 
-        self.assertEqual(set(result), get_namespaces(egf_graph))
-        self.assertEqual(Counter(result), count_namespaces(egf_graph))
-
-    def test_names_sialic(self):
-        """Test getting and counting names by namespace."""
-        result = {
+        hgnc_result = {
             'CD33': 3,  # once as reference, once in complex, and once as variant
             'TYROBP': 1,
             'SYK': 1,
@@ -71,9 +59,28 @@ class TestSummary(unittest.TestCase):
             'PTPN11': 1,
             'TREM2': 1,
         }
+        chebi_result = {
+            'sialic acid': 2,
+        }
+        names = get_names(sialic_acid_graph)
+        self.assertEqual(set(namespace_result), set(names))
+        self.assertEqual(set(hgnc_result), names['hgnc'])
+        self.assertEqual(set(chebi_result), names['chebi'])
+        self.assertEqual(set(hgnc_result), get_names_by_namespace(sialic_acid_graph, 'hgnc'))
+        self.assertEqual(set(chebi_result), get_names_by_namespace(sialic_acid_graph, 'chebi'))
+        self.assertEqual(hgnc_result, dict(count_names_by_namespace(sialic_acid_graph, 'hgnc')))
+        self.assertEqual(chebi_result, dict(count_names_by_namespace(sialic_acid_graph, 'chebi')))
 
-        self.assertEqual(set(result), get_names_by_namespace(sialic_acid_graph, 'HGNC'))
-        self.assertEqual(result, dict(count_names_by_namespace(sialic_acid_graph, 'HGNC')))
+    def test_namespaces_egf(self):
+        """Test getting and counting namespaces' contents on the EGF graph."""
+        result = {
+            'hgnc': 15,
+            'go': 3,
+            'bel': 4,
+        }
+
+        self.assertEqual(set(result), get_namespaces(egf_graph))
+        self.assertEqual(Counter(result), count_namespaces(egf_graph))
 
     def test_names_fusions(self):
         """Test that names inside fusions are still found by the iterator."""