Repository: cancervariants/disease-normalization (language: python)

Issue:
Add DO data
http://www.obofoundry.org/ontology/doid.html


Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 00000000..87164b79
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,7 @@
+[run]
+source =
+   disease/
+   tests/
+omit =
+   setup.py
+   docs/*
diff --git a/cli.py b/cli.py
index ae6d63d5..7690ae4f 100644
--- a/cli.py
+++ b/cli.py
@@ -3,6 +3,8 @@
 from disease import SOURCES_CLASS_LOOKUP, SOURCES_LOWER_LOOKUP
 from disease.schemas import SourceName
 from disease.database import Database
+from disease.etl.mondo import Mondo
+from disease.etl.merge import Merge
 from botocore.exceptions import ClientError
 from boto3.dynamodb.conditions import Key
 from timeit import default_timer as timer
@@ -30,7 +32,14 @@ class CLI:
         is_flag=True,
         help='Update all normalizer sources.'
     )
-    def update_normalizer_db(normalizer, dev, db_url, update_all):
+    @click.option(
+        '--update_merged',
+        is_flag=True,
+        help='Update concepts for normalize endpoint. Must select either'
+             '--update_all or include Mondo as a normalizer source argument.'
+    )
+    def update_normalizer_db(normalizer, dev, db_url, update_all,
+                             update_merged):
         """Update select normalizer source(s) in the disease database."""
         if dev:
             db: Database = Database(db_url='http://localhost:8000')
@@ -41,7 +50,7 @@ def update_normalizer_db(normalizer, dev, db_url, update_all):
 
         if update_all:
             normalizers = list(src for src in SOURCES_CLASS_LOOKUP)
-            CLI()._update_normalizers(normalizers, db)
+            CLI()._update_normalizers(normalizers, db, update_merged)
         elif not normalizer:
             CLI()._help_msg()
         else:
@@ -56,7 +65,10 @@ def update_normalizer_db(normalizer, dev, db_url, update_all):
             if len(non_sources) != 0:
                 raise Exception(f"Not valid source(s): {non_sources}")
 
-            CLI()._update_normalizers(normalizers, db)
+            if update_merged and 'Mondo' not in normalizers:
+                CLI()._help_msg()
+
+            CLI()._update_normalizers(normalizers, db, update_merged)
 
     def _help_msg(self):
         """Display help message."""
@@ -66,8 +78,9 @@ def _help_msg(self):
         click.echo(ctx.get_help())
         ctx.exit()
 
-    def _update_normalizers(self, normalizers, db):
+    def _update_normalizers(self, normalizers, db, update_merged):
         """Update selected normalizer sources."""
+        processed_ids = []
         for n in normalizers:
             click.echo(f"\nDeleting {n}...")
             start_delete = timer()
@@ -79,12 +92,20 @@ def _update_normalizers(self, normalizers, db):
             click.echo(f"Loading {n}...")
             start_load = timer()
             source = SOURCES_CLASS_LOOKUP[n](database=db)
-            source.perform_etl()
+            if isinstance(source, Mondo):
+                processed_ids = source.perform_etl()
+            else:
+                source.perform_etl()
             end_load = timer()
             load_time = end_load - start_load
             click.echo(f"Loaded {n} in {load_time:.5f} seconds.")
             click.echo(f"Total time for {n}: "
                        f"{(delete_time + load_time):.5f} seconds.")
+        if update_merged and processed_ids:
+            click.echo("Generating merged concepts...")
+            merge = Merge(database=db)
+            merge.create_merged_concepts(processed_ids)
+            click.echo("Merged concept generation complete.")
 
     def _delete_data(self, source, database):
         # Delete source's metadata
diff --git a/disease/__init__.py b/disease/__init__.py
index cc01116e..3a712be5 100644
--- a/disease/__init__.py
+++ b/disease/__init__.py
@@ -22,8 +22,8 @@
                         SourceName.__members__.values()}
 
 # use to fetch source name from schema based on concept id namespace
-# e.g. {'ncit': 'NCIt'}
-PREFIX_LOOKUP = {v.value: SourceName[k].value
+# e.g. {'ncit': 'NCIt', 'doid': 'DO'}
+PREFIX_LOOKUP = {v.value.lower(): SourceName[k].value
                  for k, v in NamespacePrefix.__members__.items()
                  if k in SourceName.__members__.keys()}
 
@@ -35,6 +35,7 @@
 
 from disease.etl import NCIt  # noqa: E402 F401
 from disease.etl import Mondo  # noqa: E402 F401
+from disease.etl import DO  # noqa: E402 F401
 # Use to lookup class object from source name. Should be one key-value pair
 # for every functioning ETL class.
 SOURCES_CLASS_LOOKUP = {s.value.lower(): eval(s.value)
diff --git a/disease/database.py b/disease/database.py
index 6d61305e..8ec0209f 100644
--- a/disease/database.py
+++ b/disease/database.py
@@ -6,7 +6,7 @@
 import click
 import sys
 from os import environ
-from typing import Optional, Dict, List
+from typing import Optional, Dict, List, Any
 import logging
 
 logger = logging.getLogger('disease')
@@ -88,6 +88,10 @@ def create_diseases_table(self, existing_tables: List):
                     {
                         'AttributeName': 'src_name',
                         'AttributeType': 'S'
+                    },
+                    {
+                        'AttributeName': 'item_type',
+                        'AttributeType': 'S'
                     }
 
                 ],
@@ -107,6 +111,22 @@ def create_diseases_table(self, existing_tables: List):
                             'ReadCapacityUnits': 10,
                             'WriteCapacityUnits': 10
                         }
+                    },
+                    {
+                        'IndexName': 'type_index',
+                        'KeySchema': [
+                            {
+                                'AttributeName': 'item_type',
+                                'KeyType': 'HASH'
+                            }
+                        ],
+                        'Projection': {
+                            'ProjectionType': 'KEYS_ONLY'
+                        },
+                        'ProvisionedThroughput': {
+                            'ReadCapacityUnits': 10,
+                            'WriteCapacityUnits': 10
+                        }
                     }
                 ],
                 ProvisionedThroughput={
@@ -143,7 +163,8 @@ def create_meta_data_table(self, existing_tables: List):
             )
 
     def get_record_by_id(self, concept_id: str,
-                         case_sensitive: bool = True) -> Optional[Dict]:
+                         case_sensitive: bool = True,
+                         merge: bool = False) -> Optional[Dict]:
         """Fetch record corresponding to provided concept ID
 
         :param str concept_id: concept ID for disease record
@@ -153,7 +174,10 @@ def get_record_by_id(self, concept_id: str,
         :return: complete disease record, if match is found; None otherwise
         """
         try:
-            pk = f'{concept_id.lower()}##identity'
+            if merge:
+                pk = f'{concept_id.lower()}##merger'
+            else:
+                pk = f'{concept_id.lower()}##identity'
             if case_sensitive:
                 match = self.diseases.get_item(Key={
                     'label_and_type': pk,
@@ -195,18 +219,42 @@ def get_records_by_type(self, query: str,
                          f"{e.response['Error']['Message']}")
             return []
 
-    def add_record(self, record: Dict):
+    def get_merged_record(self, merge_ref) -> Optional[Dict]:
+        """Fetch merged record from given reference.
+        :param str merge_ref: key for merged record, formated as a string
+            of grouped concept IDs separated by vertical bars, ending with
+            `##merger`. Must be correctly-cased.
+        :return: complete merged record if lookup successful, None otherwise
+        """
+        try:
+            match = self.diseases.get_item(Key={
+                'label_and_type': merge_ref.lower(),
+                'concept_id': merge_ref
+            })
+            return match['Item']
+        except ClientError as e:
+            logger.error("boto3 client error in "
+                         "`database.get_merged_record()`: "
+                         f"{e.response['Error']['Message']}")
+            return None
+        except KeyError:
+            return None
+
+    def add_record(self, record: Dict, record_type="identity"):
         """Add new record to database.
 
         :param Dict record: record to upload
+        :param str record_type: type of record (either 'identity' or 'merger')
         """
-        id_prefix = record['concept_id'].split(':')[0]
+        id_prefix = record['concept_id'].split(':')[0].lower()
         record['src_name'] = PREFIX_LOOKUP[id_prefix]
-        record['label_and_type'] = f'{record["concept_id"].lower()}##identity'
+        label_and_type = f'{record["concept_id"].lower()}##{record_type}'
+        record['label_and_type'] = label_and_type
+        record['item_type'] = record_type
         try:
             self.batch.put_item(Item=record)
         except ClientError as e:
-            logger.error(f"boto3 client error on add_record for "
+            logger.error("boto3 client error on add_record for "
                          f"{record['concept_id']}: "
                          f"{e.response['Error']['Message']}")
 
@@ -218,11 +266,12 @@ def add_ref_record(self, term: str, concept_id: str, ref_type: str):
         :param str ref_type: one of ('alias', 'label')
         """
         label_and_type = f'{term.lower()}##{ref_type}'
-        src_name = PREFIX_LOOKUP[concept_id.split(':')[0]]
+        src_name = PREFIX_LOOKUP[concept_id.split(':')[0].lower()]
         record = {
             'label_and_type': label_and_type,
             'concept_id': concept_id.lower(),
             'src_name': src_name,
+            'item_type': ref_type,
         }
         try:
             self.batch.put_item(Item=record)
@@ -230,3 +279,29 @@ def add_ref_record(self, term: str, concept_id: str, ref_type: str):
             logger.error(f"boto3 client error adding reference {term} for "
                          f"{concept_id} with match type {ref_type}: "
                          f"{e.response['Error']['Message']}")
+
+    def update_record(self, concept_id: str, field: str, new_value: Any):
+        """Update the field of an individual record to a new value.
+
+        :param str concept_id: record to update
+        :param str field: name of field to update
+        :parm str new_value: new value
+        """
+        key = {
+            'label_and_type': f'{concept_id.lower()}##identity',
+            'concept_id': concept_id
+        }
+        update_expression = f"set {field}=:r"
+        update_values = {':r': new_value}
+        try:
+            self.diseases.update_item(Key=key,
+                                      UpdateExpression=update_expression,
+                                      ExpressionAttributeValues=update_values)
+        except ClientError as e:
+            logger.error(f"boto3 client error in `database.update_record()`: "
+                         f"{e.response['Error']['Message']}")
+
+    def flush_batch(self):
+        """Flush internal batch_writer."""
+        self.batch.__exit__(*sys.exc_info())
+        self.batch = self.diseases.batch_writer()
diff --git a/disease/etl/__init__.py b/disease/etl/__init__.py
index e26b76f5..64242baa 100644
--- a/disease/etl/__init__.py
+++ b/disease/etl/__init__.py
@@ -1,3 +1,5 @@
 """Module to load and init namespace at package level."""
 from .ncit import NCIt  # noqa: F401
 from .mondo import Mondo  # noqa: F401
+from .do import DO  # noqa: F401
+from .merge import Merge  # noqa: F401
diff --git a/disease/etl/base.py b/disease/etl/base.py
index d56f0d76..4cffe43f 100644
--- a/disease/etl/base.py
+++ b/disease/etl/base.py
@@ -1,6 +1,8 @@
 """A base class for extraction, transformation, and loading of data."""
 from abc import ABC, abstractmethod
 from disease.database import Database
+import owlready2 as owl
+from typing import Set
 
 
 class Base(ABC):
@@ -15,9 +17,16 @@ def perform_etl(self):
         """Public-facing method to begin ETL procedures on given data."""
         raise NotImplementedError
 
-    @abstractmethod
-    def _extract_data(self, *args, **kwargs):
-        raise NotImplementedError
+    def _extract_data(self):
+        """Get source file from data directory."""
+        self._data_path.mkdir(exist_ok=True, parents=True)
+        src_name = type(self).__name__.lower()
+        dir_files = [f for f in self._data_path.iterdir()
+                     if f.name.startswith(src_name)]
+        if len(dir_files) == 0:
+            self._download_data()
+            dir_files = list(self._data_path.iterdir())
+        self._data_file = sorted(dir_files, reverse=True)[0]
 
     @abstractmethod
     def _transform_data(self, *args, **kwargs):
@@ -26,3 +35,38 @@ def _transform_data(self, *args, **kwargs):
     @abstractmethod
     def _load_meta(self, *args, **kwargs):
         raise NotImplementedError
+
+
+class OWLBase(Base):
+    """Base class for sources that use OWL files."""
+
+    def _get_subclasses(self, uri: str) -> Set[str]:
+        """Retrieve URIs for all terms that are subclasses of given URI.
+
+        :param str uri: URI for class
+        :return: Set of URIs (strings) for all subclasses of `uri`
+        """
+        graph = owl.default_world.as_rdflib_graph()
+        query = f"""
+            SELECT ?c WHERE {{
+                ?c rdfs:subClassOf* <{uri}>
+            }}
+            """
+        return {item.c.toPython() for item in graph.query(query)}
+
+    def _get_by_property_value(self, prop: str,
+                               value: str) -> Set[str]:
+        """Get all classes with given value for a specific property.
+
+        :param str prop: property URI
+        :param str value: property value
+        :return: Set of URIs (as strings) matching given property/value
+        """
+        graph = owl.default_world.as_rdflib_graph()
+        query = f"""
+            SELECT ?c WHERE {{
+                ?c <{prop}>
+                "{value}"
+            }}
+            """
+        return {item.c.toPython() for item in graph.query(query)}
diff --git a/disease/etl/do.py b/disease/etl/do.py
new file mode 100644
index 00000000..cfd70b30
--- /dev/null
+++ b/disease/etl/do.py
@@ -0,0 +1,145 @@
+"""Disease Ontology ETL module."""
+import logging
+from .base import OWLBase
+from pathlib import Path
+from disease import PROJECT_ROOT, PREFIX_LOOKUP
+from disease.schemas import Meta, SourceName, NamespacePrefix, Disease
+from disease.database import Database
+from datetime import datetime
+import owlready2 as owl
+from typing import Dict, List
+
+
+logger = logging.getLogger('disease')
+logger.setLevel(logging.DEBUG)
+
+DO_PREFIX_LOOKUP = {
+    "EFO": NamespacePrefix.EFO.value,
+    "GARD": NamespacePrefix.GARD.value,
+    "ICDO": NamespacePrefix.ICDO.value,
+    "MESH": NamespacePrefix.MESH.value,
+    "NCI": NamespacePrefix.NCIT.value,
+    "ORDO": NamespacePrefix.ORPHANET.value,
+    "UMLS_CUI": NamespacePrefix.UMLS.value,
+    "ICD9CM": NamespacePrefix.ICD9CM.value,
+    "ICD10CM": NamespacePrefix.ICD10CM.value,
+    "OMIM": NamespacePrefix.OMIM.value,
+    "KEGG": NamespacePrefix.KEGG.value,
+    "MEDDRA": NamespacePrefix.MEDDRA.value,
+}
+
+
+class DO(OWLBase):
+    """Disease Ontology ETL class."""
+
+    def __init__(self,
+                 database: Database,
+                 src_dload_page: str = "http://www.obofoundry.org/ontology/doid.html",  # noqa: E501
+                 src_url: str = "http://purl.obolibrary.org/obo/doid/doid-merged.owl",  # noqa: E501
+                 data_path: Path = PROJECT_ROOT / 'data' / 'do'):
+        """Override base class init method.
+
+        :param therapy.database.Database database: app database instance
+        :param str src_dload_page: URL for source data download webpage
+        :param str src_url: URL for source data file
+        :param pathlib.Path data_path: path to local DO data directory
+        """
+        self.database = database
+        self._SRC_DLOAD_PAGE = src_dload_page
+        self._SRC_URL = src_url
+        self._data_path = data_path
+        self._version = datetime.strftime(datetime.now(), "%Y%m%d")
+
+    def perform_etl(self) -> List[str]:
+        """Public-facing method to initiate ETL procedures on given data.
+
+        :return: empty list (because NCIt IDs shouldn't be used to construct
+            merged concept groups)
+        """
+        self._load_meta()
+        self._transform_data()
+        self.database.flush_batch()
+        return []
+
+    def _load_meta(self):
+        """Load metadata"""
+        metadata_params = {
+            "data_license": "CC0 1.0",
+            "data_license_url": "https://creativecommons.org/publicdomain/zero/1.0/legalcode",  # noqa: E501
+            "version": self._version,
+            "data_url": self._SRC_DLOAD_PAGE,
+            "rdp_url": None,
+            "data_license_attributes": {
+                "non_commercial": False,
+                "share_alike": False,
+                "attribution": False
+            }
+        }
+        assert Meta(**metadata_params)
+        metadata_params['src_name'] = SourceName.DO.value
+        self.database.metadata.put_item(Item=metadata_params)
+
+    def _transform_data(self):
+        """Transform source data and send to loading method."""
+        do = owl.get_ontology(self._SRC_URL).load()
+        disease_uri = 'http://purl.obolibrary.org/obo/DOID_4'
+        diseases = self._get_subclasses(disease_uri)
+        for uri in diseases:
+            disease_class = do.search(iri=uri)[0]
+            if disease_class.deprecated:
+                continue
+
+            concept_id = f"{NamespacePrefix.DO.value}:{uri.split('_')[-1]}"
+            label = disease_class.label[0]
+
+            synonyms = disease_class.hasExactSynonym
+            if synonyms:
+                aliases = list({s for s in synonyms if s != label})
+            else:
+                aliases = []
+
+            other_ids = []
+            xrefs = []
+            db_xrefs = set(disease_class.hasDbXref)
+            for other_id in db_xrefs:
+                prefix, id_no = other_id.split(':', 1)
+                normed_prefix = DO_PREFIX_LOOKUP.get(prefix, None)
+                if normed_prefix:
+                    other_id_no = f'{normed_prefix}:{id_no}'
+                    if normed_prefix.lower() in PREFIX_LOOKUP:
+                        other_ids.append(other_id_no)
+                    else:
+                        xrefs.append(other_id_no)
+
+            disease = {
+                "concept_id": concept_id,
+                "label": label,
+                "aliases": aliases,
+                "other_identifiers": other_ids,
+                "xrefs": xrefs
+            }
+            assert Disease(**disease)
+            self._load_disease(disease)
+
+    def _load_disease(self, disease: Dict):
+        """Load individual disease record along with reference items.
+
+        :param Dict disease: disease record to load
+        """
+        concept_id = disease['concept_id']
+        aliases = disease['aliases']
+        if len({a.casefold() for a in aliases}) > 20:
+            logger.debug(f'{concept_id} has > 20 aliases')
+            del disease['aliases']
+        elif not aliases:
+            del disease['aliases']
+        else:
+            case_uq_aliases = {a.lower() for a in disease['aliases']}
+            for alias in case_uq_aliases:
+                self.database.add_ref_record(alias, concept_id, 'alias')
+        for key in ['other_identifiers', 'xrefs']:
+            if not disease[key]:
+                del disease[key]
+        self.database.add_ref_record(disease['label'], concept_id,
+                                     'label')
+        self.database.add_record(disease)
diff --git a/disease/etl/merge.py b/disease/etl/merge.py
new file mode 100644
index 00000000..6b651067
--- /dev/null
+++ b/disease/etl/merge.py
@@ -0,0 +1,132 @@
+"""Create concept groups and merged records."""
+from disease.database import Database
+from disease.schemas import SourcePriority
+from typing import Set, Dict, List
+import logging
+from timeit import default_timer as timer
+
+logger = logging.getLogger('disease')
+logger.setLevel(logging.DEBUG)
+
+
+class Merge:
+    """Handles record merging."""
+
+    def __init__(self, database: Database):
+        """Initialize Merge instance.
+
+        :param Database database: db instance to use for record retrieval
+            and creation.
+        """
+        self._database = database
+        self._groups = []  # list of tuples: (mondo_concept_id, set_of_ids)
+
+    def create_merged_concepts(self, record_ids: Set[str]):
+        """Create concept groups, generate merged concept records, and
+        update database.
+
+        :param Set[str] record_ids: concept identifiers from which groups
+            should be generated.
+        """
+        # build groups
+        logger.info(f'Generating record ID sets from {len(record_ids)} records')  # noqa E501
+        start = timer()
+        for concept_id in record_ids:
+            try:
+                record = self._database.get_record_by_id(concept_id)
+            except AttributeError:
+                logger.error(f"`create_merged_concepts` received invalid "
+                             f"concept ID: {concept_id}")
+                continue
+            if not record:
+                logger.error(f"generate_merged_concepts couldn't find "
+                             f"{concept_id}")
+                continue
+            other_ids = record.get('other_identifiers', None)
+            if other_ids:
+                group = set(other_ids + [concept_id])
+            else:
+                group = {concept_id}
+            self._groups.append((concept_id, group))
+        end = timer()
+        logger.debug(f'Built record ID sets in {end - start} seconds')
+
+        # build merged concepts
+        logger.info('Creating merged records and updating database...')
+        start = timer()
+        for record_id, group in self._groups:
+            try:
+                merged_record, merged_ids = self._generate_merged_record(group)
+            except AttributeError:
+                logger.error("`create_merged_concepts` received invalid group:"
+                             f"{group} for concept {record_id}")
+                continue
+            self._database.add_record(merged_record, 'merger')
+            merge_ref = merged_record['concept_id'].lower()
+
+            for concept_id in merged_ids:
+                self._database.update_record(concept_id, 'merge_ref',
+                                             merge_ref)
+        end = timer()
+        logger.info("merged concept generation successful.")
+        logger.debug(f'Generated and added concepts in {end - start} seconds)')
+
+    def _generate_merged_record(self, record_id_set: Set[str]) -> (Dict, List):
+        """Generate merged record from provided concept ID group.
+        Where attributes are sets, they should be merged, and where they are
+        scalars, assign from the highest-priority source where that attribute
+        is non-null.
+
+        Priority is NCIt > Mondo > DO.
+        :param Set record_id_set: group of concept IDs
+        :return: completed merged drug object to be stored in DB, as well as
+            a list of the IDs ultimately included in said record
+        """
+        records = []
+        final_ids = []
+        for record_id in record_id_set:
+            record = self._database.get_record_by_id(record_id)
+            if record:
+                records.append(record)
+                final_ids.append(record['concept_id'])
+            else:
+                logger.error(f"generate_merged_record could not retrieve "
+                             f"record for {record_id} in {record_id_set}")
+
+        def record_order(record):
+            """Provide priority values of concepts for sort function."""
+            src = record['src_name'].upper()
+            source_rank = SourcePriority[src].value
+            return (source_rank, record['concept_id'])
+        records.sort(key=record_order)
+
+        merged_properties = {
+            'concept_id': None,
+            'aliases': set(),
+            'xrefs': set()
+        }
+        set_fields = ['aliases', 'xrefs']
+        scalar_fields = ['label', 'pediatric_disease']
+        for record in records:
+            for field in set_fields:
+                if field in record:
+                    merged_properties[field] |= set(record[field])
+            if not merged_properties['concept_id']:
+                merged_properties['concept_id'] = record['concept_id']
+            else:
+                merged_properties['concept_id'] += f"|{record['concept_id']}"
+
+            for field in scalar_fields:
+                if field not in merged_properties and field in record:
+                    merged_properties[field] = record[field]
+
+        for field in set_fields:
+            field_value = merged_properties[field]
+            if field_value:
+                merged_properties[field] = list(field_value)
+            else:
+                del merged_properties[field]
+
+        merged_properties['label_and_type'] = \
+            f'{merged_properties["concept_id"].lower()}##merger'
+        return (merged_properties, final_ids)
diff --git a/disease/etl/mondo.py b/disease/etl/mondo.py
index 7c4ba9e5..c003dd83 100644
--- a/disease/etl/mondo.py
+++ b/disease/etl/mondo.py
@@ -1,14 +1,13 @@
 """Module to load disease data from Mondo Disease Ontology."""
-from .base import Base
+from .base import OWLBase
 import logging
 from disease import PROJECT_ROOT, PREFIX_LOOKUP
 from disease.database import Database
 from disease.schemas import Meta, SourceName, NamespacePrefix, Disease
 from pathlib import Path
 import requests
-from typing import Set, Dict
 import owlready2 as owl
-from rdflib.term import URIRef
+from typing import Dict, List
 
 
 logger = logging.getLogger('disease')
@@ -16,46 +15,48 @@
 
 
 MONDO_PREFIX_LOOKUP = {
-    "NCIT": NamespacePrefix.NCIT,
-    "DOID": NamespacePrefix.DO,
-    "OGMS": NamespacePrefix.OGMS,
-    "Orphanet": NamespacePrefix.ORPHANET,
-    "MESH": NamespacePrefix.MESH,
-    "EFO": NamespacePrefix.EFO,
-    "UMLS": NamespacePrefix.UMLS,
-    "UMLS_CUI": NamespacePrefix.UMLS,
-    "ICD9": NamespacePrefix.ICD9,
-    "ICD9CM": NamespacePrefix.ICD9CM,
-    "ICD10": NamespacePrefix.ICD10,
-    "ICD10CM": NamespacePrefix.ICD10CM,
-    "ICDO": NamespacePrefix.ICDO,
-    "IDO": NamespacePrefix.IDO,
-    "GARD": NamespacePrefix.GARD,
-    "OMIM": NamespacePrefix.OMIM,
-    "OMIMPS": NamespacePrefix.OMIMPS,
-    "KEGG": NamespacePrefix.KEGG,
-    "COHD": NamespacePrefix.COHD,
-    "HPO": NamespacePrefix.HPO,
-    "NIFSTD": NamespacePrefix.NIFSTD,
-    "MF": NamespacePrefix.MF,
-    "HP": NamespacePrefix.HPO,
-    "MedDRA": NamespacePrefix.MEDDRA,
-    "MEDDRA": NamespacePrefix.MEDDRA,
-    "ONCOTREE": NamespacePrefix.ONCOTREE,
-    "Wikipedia": NamespacePrefix.WIKIPEDIA,
-    "Wikidata": NamespacePrefix.WIKIDATA,
-    "MEDGEN": NamespacePrefix.MEDGEN,
-    "MP": NamespacePrefix.MP,
-    "PATO": NamespacePrefix.PATO,
+    # built-in sources
+    "NCIT": NamespacePrefix.NCIT.value,
+    "DOID": NamespacePrefix.DO.value,
+    # external sources
+    "COHD": NamespacePrefix.COHD.value,
+    "EFO": NamespacePrefix.EFO.value,
+    "GARD": NamespacePrefix.GARD.value,
+    "HP": NamespacePrefix.HPO.value,
+    "HPO": NamespacePrefix.HPO.value,
+    "ICD9": NamespacePrefix.ICD9.value,
+    "ICD9CM": NamespacePrefix.ICD9CM.value,
+    "ICD10": NamespacePrefix.ICD10.value,
+    "ICD10CM": NamespacePrefix.ICD10CM.value,
+    "ICDO": NamespacePrefix.ICDO.value,
+    "IDO": NamespacePrefix.IDO.value,
+    "KEGG": NamespacePrefix.KEGG.value,
+    "MedDRA": NamespacePrefix.MEDDRA.value,
+    "MEDDRA": NamespacePrefix.MEDDRA.value,
+    "MEDGEN": NamespacePrefix.MEDGEN.value,
+    "MESH": NamespacePrefix.MESH.value,
+    "MF": NamespacePrefix.MF.value,
+    "MP": NamespacePrefix.MP.value,
+    "NIFSTD": NamespacePrefix.NIFSTD.value,
+    "OGMS": NamespacePrefix.OGMS.value,
+    "OMIM": NamespacePrefix.OMIM.value,
+    "OMIMPS": NamespacePrefix.OMIMPS.value,
+    "ONCOTREE": NamespacePrefix.ONCOTREE.value,
+    "Orphanet": NamespacePrefix.ORPHANET.value,
+    "PATO": NamespacePrefix.PATO.value,
+    "UMLS": NamespacePrefix.UMLS.value,
+    "UMLS_CUI": NamespacePrefix.UMLS.value,
+    "Wikidata": NamespacePrefix.WIKIDATA.value,
+    "Wikipedia": NamespacePrefix.WIKIPEDIA.value,
 }
 
 
-class Mondo(Base):
+class Mondo(OWLBase):
     """Gather and load data from Mondo."""
 
     def __init__(self,
                  database: Database,
-                 src_dload_page: str = "https://mondo.monarchinitiative.org/pages/download/",  # noqa F401
+                 src_dload_page: str = "https://mondo.monarchinitiative.org/pages/download/",  # noqa: E501
                  src_url: str = "http://purl.obolibrary.org/obo/mondo.owl",
                  version: str = "20210129",
                  data_path: Path = PROJECT_ROOT / 'data' / 'mondo'):
@@ -71,12 +72,18 @@ def __init__(self,
         self._SRC_URL = src_url
         self._version = version
         self._data_path = data_path
+        self._processed_ids = []
 
-    def perform_etl(self):
-        """Public-facing method to initiate ETL procedures on given data."""
+    def perform_etl(self) -> List[str]:
+        """Public-facing method to initiate ETL procedures on given data.
+
+        :return: List of concept IDs that were added.
+        """
         self._extract_data()
         self._load_meta()
         self._transform_data()
+        self.database.flush_batch()
+        return self._processed_ids
 
     def _download_data(self):
         """Download Mondo thesaurus source file for loading into normalizer."""
@@ -92,19 +99,10 @@ def _download_data(self):
                 handle.write(chunk)
         logger.info('Finished downloading Mondo Disease Ontology')
 
-    def _extract_data(self):
-        """Get Mondo source file."""
-        self._data_path.mkdir(exist_ok=True, parents=True)
-        dir_files = list(self._data_path.iterdir())
-        if len(dir_files) == 0:
-            self._download_data()
-            dir_files = list(self._data_path.iterdir())
-        self._data_file = sorted(dir_files)[-1]
-
     def _load_meta(self):
         """Load metadata"""
         metadata = Meta(data_license="CC BY 4.0",
-                        data_license_url="https://creativecommons.org/licenses/by/4.0/legalcode",  # noqa F401
+                        data_license_url="https://creativecommons.org/licenses/by/4.0/legalcode",  # noqa: E501
                         version=self._version,
                         data_url=self._SRC_DLOAD_PAGE,
                         rdp_url='http://reusabledata.org/monarch.html',
@@ -117,49 +115,34 @@ def _load_meta(self):
         params['src_name'] = SourceName.MONDO.value
         self.database.metadata.put_item(Item=params)
 
-    def _collect_subclasses(self, uri) -> Set[URIRef]:
-        """Retrieve URIs for all terms that are subclasses of given URI.
-
-        :param str uri: uri of superclass
-        :return: Set of URIs for all classes that are subclasses of it
-        """
-        graph = owl.default_world.as_rdflib_graph()
-        query = f"""
-        SELECT ?c WHERE {{
-        ?c rdfs:subClassOf* <{uri}>
-        }}
-        """
-        return {item.c for item in graph.query(query)}
-
     def _transform_data(self):
         """Gather and transform disease entities."""
-        mondo = owl.get_ontology(self._data_file.absolute().as_uri())
-        mondo.load()
+        mondo = owl.get_ontology(self._data_file.absolute().as_uri()).load()
 
         # gather constants/search materials
         disease_root = "http://purl.obolibrary.org/obo/MONDO_0000001"
-        disease_uris = self._collect_subclasses(disease_root)
+        disease_uris = self._get_subclasses(disease_root)
         peds_neoplasm_root = "http://purl.obolibrary.org/obo/MONDO_0006517"
-        peds_uris = {u.toPython() for u
-                     in self._collect_subclasses(peds_neoplasm_root)}
-        adult_onset_pattern = 'http://purl.obolibrary.org/obo/mondo/patterns/adult.yaml'  # noqa: E501
+        peds_uris = self._get_subclasses(peds_neoplasm_root)
 
         for uri in disease_uris:
             try:
                 disease = mondo.search(iri=uri)[0]
             except TypeError:
-                logger.error(f"Could not retrieve class for URI {uri}")
+                logger.error(f"Mondo.transform_data could not retrieve class "
+                             f"for URI {uri}")
                 continue
             try:
                 label = disease.label[0]
             except IndexError:
-                logger.debug(f"No label for concept {uri}")
+                logger.debug(f"No label for Mondo concept {uri}")
                 continue
 
+            aliases = list({d for d in disease.hasExactSynonym if d != label})
             params = {
                 'concept_id': disease.id[0].lower(),
                 'label': label,
-                'aliases': list({d for d in disease.hasExactSynonym if d != label}),  # noqa: E501
+                'aliases': aliases,
                 'xrefs': [],
                 'other_identifiers': []
             }
@@ -169,45 +152,45 @@ def _transform_data(self):
                 normed_prefix = MONDO_PREFIX_LOOKUP.get(prefix, None)
                 if not normed_prefix:
                     continue
-                other_id = f'{normed_prefix.value}:{id_no}'
+                other_id = f'{normed_prefix}:{id_no}'
 
-                if normed_prefix.value in PREFIX_LOOKUP:
+                if normed_prefix.lower() in PREFIX_LOOKUP:
                     params['other_identifiers'].append(other_id)
                 elif normed_prefix == NamespacePrefix.KEGG:
-                    other_id = f'{normed_prefix.value}:H{id_no}'
+                    other_id = f'{normed_prefix}:H{id_no}'
                     params['xrefs'].append(other_id)
                 else:
                     params['xrefs'].append(other_id)
 
             if disease.iri in peds_uris:
-                params['pediatric'] = True
-            else:
-                conforms_to = disease.conformsTo
-                if conforms_to and adult_onset_pattern in conforms_to:
-                    params['pediatric'] = False
+                params['pediatric_disease'] = True
 
             assert Disease(**params)  # check input validity
             self._load_disease(params)
 
     def _load_disease(self, disease: Dict):
-        """Load individual disease and associated references.
+        """Load individual disease and associated references. Stores disease
+        concept_id in `self._processed_ids` attribute.
 
         :param Dict disease: individual disease record to be loaded
         """
         concept_id = disease['concept_id']
-        aliases = disease['aliases']
+
+        aliases = {a.lower() for a in disease['aliases']}
         if aliases:
-            if len({a.casefold() for a in aliases}) > 20:
-                logger.debug(f'{concept_id} has > 20 aliases')
+            if len(aliases) > 20:
+                logger.debug(f'{concept_id} has >20 aliases')
                 del disease['aliases']
             else:
                 for alias in aliases:
                     self.database.add_ref_record(alias, concept_id, 'alias')
         else:
             del disease['aliases']
+
         for key in ('xrefs', 'other_identifiers'):
             if not disease[key]:
                 del disease[key]
 
         self.database.add_record(disease)
         self.database.add_ref_record(disease['label'], concept_id, 'label')
+        self._processed_ids.append(concept_id)
diff --git a/disease/etl/ncit.py b/disease/etl/ncit.py
index aff6dfda..5d90c0fd 100644
--- a/disease/etl/ncit.py
+++ b/disease/etl/ncit.py
@@ -1,6 +1,6 @@
 """Module to load disease data from NCIt."""
 import logging
-from .base import Base
+from .base import OWLBase
 from disease import PROJECT_ROOT
 from disease.database import Database
 from disease.schemas import Meta, SourceName, NamespacePrefix, Disease
@@ -8,7 +8,7 @@
 import requests
 import zipfile
 from os import remove, rename
-from typing import Set, Dict
+from typing import Set, Dict, List
 import owlready2 as owl
 from owlready2.entity import ThingClass
 import re
@@ -20,7 +20,7 @@
 icdo_re = re.compile("[0-9]+/[0-9]+")
 
 
-class NCIt(Base):
+class NCIt(OWLBase):
     """Gather and load data from NCIt."""
 
     def __init__(self,
@@ -40,11 +40,17 @@ def __init__(self,
         self._SRC_FNAME = src_fname
         self._data_path = data_path
 
-    def perform_etl(self):
-        """Public-facing method to initiate ETL procedures on given data."""
+    def perform_etl(self) -> List[str]:
+        """Public-facing method to initiate ETL procedures on given data.
+
+        :return: empty set (because NCIt IDs shouldn't be used to construct
+            merged concept groups)
+        """
         self._extract_data()
         self._load_meta()
         self._transform_data()
+        self.database.flush_batch()
+        return []
 
     def _download_data(self):
         """Download NCI thesaurus source file for loading into normalizer."""
@@ -65,23 +71,14 @@ def _download_data(self):
         remove(zip_path)
         version = self._SRC_DIR.split('/')[-2].split('_')[0]
         rename(self._data_path / 'Thesaurus.owl', self._data_path / f'ncit_{version}.owl')  # noqa: E501
+        self._version = version
         logger.info('Finished downloading NCI Thesaurus')
 
-    def _extract_data(self):
-        """Get NCIt source file."""
-        self._data_path.mkdir(exist_ok=True, parents=True)
-        dir_files = list(self._data_path.iterdir())
-        if len(dir_files) == 0:
-            self._download_data()
-            dir_files = list(self._data_path.iterdir())
-        self._data_file = sorted(dir_files)[-1]
-        self._version = self._data_file.stem.split('_')[1]
-
     def _load_meta(self):
         """Load metadata"""
         metadata = Meta(data_license="CC BY 4.0",
                         data_license_url="https://creativecommons.org/licenses/by/4.0/legalcode",  # noqa F401
-                        version=self._version,
+                        version=self._data_file.stem.split('_')[1],
                         data_url=self._SRC_DIR,
                         rdp_url='http://reusabledata.org/ncit.html',
                         data_license_attributes={
@@ -93,76 +90,48 @@ def _load_meta(self):
         params['src_name'] = SourceName.NCIT.value
         self.database.metadata.put_item(Item=params)
 
-    def _get_typed_nodes(self, uq_nodes: Set[ThingClass],
-                         ncit: owl.namespace.Ontology) -> Set[ThingClass]:
+    def _get_disease_classes(self) -> Set[ThingClass]:
         """Get all nodes with semantic_type 'Neoplastic Process' or 'Disease
         or Syndrome'.
 
-        :param Set[owlready2.entity.ThingClass] uq_nodes: set of unique class
-            nodes found so far.
-        :param owl.namespace.Ontology ncit: owlready2 Ontology instance for
-            NCI Thesaurus.
         :return: uq_nodes with additions from above types added
         :rtype: Set[owlready2.entity.ThingClass]
         """
-        graph = owl.default_world.as_rdflib_graph()
-
-        neopl_query_str = '''SELECT ?x WHERE {
-            ?x <http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl#P106>
-            "Neoplastic Process"
-        }'''
-        neopl_results = set(graph.query(neopl_query_str))
-
-        dos_query_str = '''SELECT ?x WHERE {
-            ?x <http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl#P106>
-            "Disease or Syndrome"
-        }'''
-        dos_results = set(graph.query(dos_query_str))
-
-        retired_query_str = '''SELECT ?x WHERE {
-            ?x <http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl#P310>
-            "Retired_Concept"
-        }
-        '''
-        retired_results = set(graph.query(retired_query_str))
-
-        typed_results = neopl_results.union(dos_results) - retired_results
-
-        for result in typed_results:
-            # parse result as URI and get ThingClass object back from NCIt
-            class_object = ncit[result[0].toPython().split('#')[1]]
-            uq_nodes.add(class_object)
-        return uq_nodes
+        p106 = "http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl#P106"
+        neopl = self._get_by_property_value(p106, "Neoplastic Process")
+        dos = self._get_by_property_value(p106, "Disease or Syndrome")
+        p310 = "http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl#P106"
+        retired = self._get_by_property_value(p310, "Retired_Concept")
+        uris = neopl.union(dos) - retired
+        return uris
 
     def _transform_data(self):
         """Get data from file and construct object for loading."""
-        ncit = owl.get_ontology(self._data_file.absolute().as_uri())
-        ncit.load()
-        uq_nodes = set()
-        uq_nodes = self._get_typed_nodes(uq_nodes, ncit)
-
-        for node in uq_nodes:
-            concept_id = f"{NamespacePrefix.NCIT.value}:{node.name}"
-            if node.P108:
-                label = node.P108.first()
+        ncit = owl.get_ontology(self._data_file.absolute().as_uri()).load()
+        disease_uris = self._get_disease_classes()
+        for uri in disease_uris:
+            disease_class = ncit.search(iri=uri)[0]
+            concept_id = f"{NamespacePrefix.NCIT.value}:{disease_class.name}"
+            if disease_class.P108:
+                label = disease_class.P108.first()
             else:
                 logger.warning(f"No label for concept {concept_id}")
                 continue
-            aliases = node.P90
+            aliases = disease_class.P90
             if aliases and label in aliases:
                 aliases.remove(label)
 
             xrefs = []
-            if node.P207:
+            if disease_class.P207:
                 xrefs.append(f"{NamespacePrefix.UMLS.value}:"
-                             f"{node.P207.first()}")
-            maps_to = node.P375
+                             f"{disease_class.P207.first()}")
+            maps_to = disease_class.P375
             if maps_to:
                 icdo_list = list(filter(lambda s: icdo_re.match(s), maps_to))
                 if len(icdo_list) == 1:
                     xrefs.append(f"{NamespacePrefix.ICDO.value}:"
                                  f"{icdo_list[0]}")
-            imdrf = node.hasDbXref
+            imdrf = disease_class.hasDbXref
             if imdrf:
                 xrefs.append(f"{NamespacePrefix.IMDRF.value}:"
                              f"{imdrf[0].split(':')[1]}")
@@ -187,7 +156,7 @@ def _load_disease(self, disease: Dict):
         if len({a.casefold() for a in aliases}) > 20:
             logger.debug(f'{concept_id} has > 20 aliases')
             del disease['aliases']
-        elif not disease['aliases']:
+        elif not aliases:
             del disease['aliases']
         else:
             disease['aliases'] = list(set(aliases))
diff --git a/disease/query.py b/disease/query.py
index 1eb56bde..a0430ea2 100644
--- a/disease/query.py
+++ b/disease/query.py
@@ -4,7 +4,7 @@
 from uvicorn.config import logger
 from disease import NAMESPACE_LOOKUP, PREFIX_LOOKUP, SOURCES_LOWER_LOOKUP
 from disease.database import Database
-from disease.schemas import Disease, Meta, MatchType
+from disease.schemas import Disease, Meta, MatchType, SourceName
 from botocore.exceptions import ClientError
 
 
@@ -310,3 +310,115 @@ def search_sources(self, query_str, keyed=False, incl='', excl='') -> Dict:
             response = self._response_list(query_str, query_sources)
 
         return response
+
+    def _add_merged_meta(self, response: Dict) -> Dict:
+        """Add source metadata to response object.
+        :param Dict response: in-progress response object
+        :return: completed resopnse object.
+        """
+        sources_meta = {}
+        for concept_id in response['record']['concept_ids']:
+            prefix = concept_id.split(':')[0]
+            src_name = PREFIX_LOOKUP[prefix.lower()]
+            if src_name not in sources_meta:
+                sources_meta[src_name] = self._fetch_meta(src_name)
+        response['meta_'] = sources_meta
+        return response
+
+    def _format_merged_record(self, merged_record: Dict) -> Dict:
+        """Transform merged record (as served by DB) into response object
+        format.
+        :param Dict merged_record: merged record as returned by DB call
+        :return: formatted record
+        """
+        del merged_record['label_and_type']
+        merged_record['concept_ids'] = merged_record['concept_id'].split('|')
+        del merged_record['concept_id']
+        return merged_record
+
+    def _add_merged_record(self, response: Dict, merge_ref: str) -> Dict:
+        """Add referenced concept ID group to response object.
+        :param Dict response: in-progress response object. Should have
+            match_type attribute filled in already.
+        :param str merge_ref: primary key for concept ID group lookup.
+        :return: completed response object.
+        """
+        merged_record = self.db.get_record_by_id(merge_ref, False, True)
+        if not merged_record:
+            logger.warning(f"Could not retrieve merged record for concept "
+                           f"ID group {merge_ref} "
+                           f"by way of query {response['query']}")
+            response['match_type'] = MatchType.NO_MATCH
+            return response
+        response['record'] = self._format_merged_record(merged_record)
+        response = self._add_merged_meta(response)
+        return response
+
+    def _record_order(self, record: Dict) -> (int, str):
+        """Construct priority order for matching. Only called by sort().
+        :param Dict record: individual record item in iterable to sort
+        :return: tuple with rank value and concept ID
+        """
+        # TODO reevaluate/update
+        src = record['src_name']
+        if src == SourceName.NCIT.value:
+            source_rank = 1
+        elif src == SourceName.MONDO.value:
+            source_rank = 2
+        elif src == SourceName.DO.value:
+            source_rank = 3
+        else:
+            logger.warning(f"query.record_order: Invalid source name for "
+                           f"{record}")
+        return (source_rank, record['concept_id'])
+
+    def search_groups(self, query_str: str) -> Dict:
+        """Return merged, normalized concept for given search term.
+        :param str query_str: string to search against
+        """
+        # prepare basic response
+        response = {
+            'query': query_str,
+            'warnings': self._emit_warnings(query_str),
+        }
+        if query_str == '':
+            response['match_type'] = MatchType.NO_MATCH
+            return response
+        query_str = query_str.lower()
+
+        # check merged concept ID match
+        record = self.db.get_record_by_id(query_str, case_sensitive=False,
+                                          merge=True)
+        if record:
+            response['match_type'] = MatchType.CONCEPT_ID
+            response['record'] = self._format_merged_record(record)
+            response = self._add_merged_meta(response)
+            return response
+
+        # check concept ID match
+        record = self.db.get_record_by_id(query_str, case_sensitive=False)
+        if record:
+            response['match_type'] = MatchType.CONCEPT_ID
+            response = self._add_merged_record(response, record['merge_ref'])
+            return response
+
+        # check other match types
+        for match_type in ['label', 'alias']:
+            # get matches list for match tier
+            query_matches = self.db.get_records_by_type(query_str, match_type)
+            query_matches = [self.db.get_record_by_id(m['concept_id'], False)
+                             for m in query_matches]
+            query_matches.sort(key=self._record_order)
+
+            # attempt merge ref resolution until successful
+            for match in query_matches:
+                record = self.db.get_record_by_id(match['concept_id'], False)
+                if record:
+                    merge_ref = record['merge_ref']
+                    if merge_ref:
+                        response['match_type'] = MatchType[match_type.upper()]
+                        return self._add_merged_record(response, merge_ref)
+
+        if not query_matches:
+            response['match_type'] = MatchType.NO_MATCH
+        return response
diff --git a/disease/schemas.py b/disease/schemas.py
index 2a464e1f..1153ef03 100644
--- a/disease/schemas.py
+++ b/disease/schemas.py
@@ -21,6 +21,7 @@ class SourceName(Enum):
 
     NCIT = "NCIt"
     MONDO = "Mondo"
+    DO = "DO"
 
 
 class SourceIDAfterNamespace(Enum):
@@ -28,6 +29,7 @@ class SourceIDAfterNamespace(Enum):
 
     NCIT = "C"
     MONDO = ""
+    DO = ""
 
 
 class NamespacePrefix(Enum):
@@ -35,36 +37,46 @@ class NamespacePrefix(Enum):
     stored.
     """
 
+    # built-in sources
     NCIT = "ncit"
     MONDO = "mondo"
-    UMLS = "umls"
     DO = "DOID"
+    # external sources
+    COHD = "cohd"
     EFO = "efo"
+    GARD = "gard"
+    HPO = "HP"
     ICD9 = "icd9"
     ICD9CM = "icd9.cm"
     ICD10 = "icd"
     ICD10CM = "icd10.cm"
     ICDO = "icd.o"
-    ORPHANET = "orphanet"
-    OGMS = "ogms"
-    MESH = "mesh"
     IDO = "ido"
-    GARD = "gard"
-    OMIM = "omim"
-    OMIMPS = "omimps"
-    KEGG = "kegg.disease"
-    COHD = "cohd"
-    HPO = "HP"
-    NIFSTD = "nifstd"
-    MF = "mf"
     IMDRF = "imdrf"
+    KEGG = "kegg.disease"
     MEDDRA = "meddra"
-    ONCOTREE = "oncotree"
-    WIKIPEDIA = "wikipedia.en"
-    WIKIDATA = "wikidata"
     MEDGEN = "medgen"
+    MESH = "mesh"
+    MF = "mf"
     MP = "MP"
+    NIFSTD = "nifstd"
+    OGMS = "ogms"
+    OMIM = "omim"
+    OMIMPS = "omimps"
+    ONCOTREE = "oncotree"
+    ORPHANET = "orphanet"
     PATO = "pato"
+    UMLS = "umls"
+    WIKIPEDIA = "wikipedia.en"
+    WIKIDATA = "wikidata"
+
+
+class SourcePriority(IntEnum):
+    """Define priorities for sources in building merged concepts."""
+
+    NCIT = 1
+    MONDO = 2
+    DO = 3
 
 
 class Disease(BaseModel):
@@ -75,7 +87,7 @@ class Disease(BaseModel):
     aliases: Optional[List[str]]
     other_identifiers: Optional[List[str]]
     xrefs: Optional[List[str]]
-    pediatric: Optional[bool]
+    pediatric_disease: Optional[bool]
 
     class Config:
         """Configure model."""
@@ -100,7 +112,7 @@ def schema_extra(schema: Dict[str, Any],
                 ],
                 "other_identifiers": [],
                 "xrefs": ["umls:C0019562"],
-                "pediatric": None,
+                "pediatric_disease": None,
             }
 
 
@@ -181,7 +193,7 @@ def schema_extra(schema: Dict[str, Any],
                     ],
                     "other_identifiers": [],
                     "xrefs": ["umls:C0019562"],
-                    "pediatric": None,
+                    "pediatric_disease": None,
                 }],
                 "meta_": {
                     "data_license": "CC BY 4.0",
@@ -234,7 +246,7 @@ def schema_extra(schema: Dict[str, Any],
                     ],
                     "other_identifiers": [],
                     "xrefs": ["umls:C0019562"],
-                    "pediatric": None
+                    "pediatric_disease": None
                 }],
                 "meta_": {
                     "data_license": "CC BY 4.0",
@@ -251,6 +263,52 @@ def schema_extra(schema: Dict[str, Any],
             }
 
 
+class MergedMatch(BaseModel):
+    """Represent merged concept in response to client."""
+
+    label: Optional[str]
+    concept_ids: List[str]
+    aliases: Optional[List[str]]
+    xrefs: Optional[List[str]]
+    pediatric_disease: Optional[bool]
+
+    class Config:
+        """Enables orm_mode"""
+
+        @staticmethod
+        def schema_extra(schema: Dict[str, Any],
+                         model: Type['MergedMatch']) -> None:
+            """Configure OpenAPI schema"""
+            if 'title' in schema.keys():
+                schema.pop('title', None)
+            for prop in schema.get('properties', {}).values():
+                prop.pop('title', None)
+            schema['example'] = {}  # TODO
+
+
+class NormalizationService(BaseModel):
+    """Response containing one or more merged records and source data."""
+
+    query: str
+    warnings: Optional[Dict]
+    match_type: MatchType
+    record: Optional[MergedMatch]
+    meta_: Optional[Dict[SourceName, Meta]]
+
+    class Config:
+        """Enables orm_mode"""
+
+        @staticmethod
+        def schema_extra(schema: Dict[str, Any],
+                         model: Type['NormalizationService']) -> None:
+            """Configure OpenAPI schema"""
+            if 'title' in schema.keys():
+                schema.pop('title', None)
+            for prop in schema.get('properties', {}).values():
+                prop.pop('title', None)
+            schema['example'] = {}  # TODO
+
+
 class Service(BaseModel):
     """Core response schema containing matches for each source"""
 
@@ -287,7 +345,7 @@ def schema_extra(schema: Dict[str, Any],
                         ],
                         "other_identifiers": [],
                         "xrefs": ["umls:C0019562"],
-                        "pediatric": None,
+                        "pediatric_disease": None,
                     }],
                     "meta_": {
                         "data_license": "CC BY 4.0",
diff --git a/main.py b/main.py
index 57d5181a..0e82cce7 100644
--- a/main.py
+++ b/main.py
@@ -1,6 +1,6 @@
 """Main application for FastAPI"""
 from disease.query import QueryHandler, InvalidParameterException
-from disease.schemas import Service
+from disease.schemas import Service, NormalizationService
 from fastapi import FastAPI, HTTPException, Query
 from fastapi.openapi.utils import get_openapi
 import html
@@ -52,6 +52,8 @@ def custom_openapi():
               "response. Will include all other sources. Will return HTTP "
               "status code 422: Unprocessable Entity if both 'incl' and"
               "'excl' parameters are given.")
+normalize_description = ("Return merged strongest-match concept for query "
+                         "string provided by user.")
 
 
 @app.get("/disease/search",
@@ -73,3 +75,26 @@ def search(q: str = Query(..., description=q_descr),
     except InvalidParameterException as e:
         raise HTTPException(status_code=422, detail=str(e))
     return response
+
+
+merged_matches_summary = "Given query, provide normalized record."
+merged_response_descr = "A response to a validly-formed query."
+merged_q_descr = "Disease to normalize."
+
+
+@app.get("/disease/normalize",
+         summary=merged_matches_summary,
+         operation_id="getQuerymergedResponse",
+         response_description=merged_response_descr,
+         response_model=NormalizationService,
+         description=normalize_description)
+def normalize(q: str = Query(..., description=merged_q_descr)):
+    """Return strongest-match normalized concept for query string provided by
+    user.
+    :param q: therapy search term
+    """
+    try:
+        response = query_handler.search_groups(q)
+    except InvalidParameterException as e:
+        raise HTTPException(status_code=422, detail=str(e))
+    return response


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..f1867cc9
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,128 @@
+"""Pytest test config tools."""
+from disease.database import Database
+from disease import PROJECT_ROOT
+from typing import Dict, Any, Optional, List
+import json
+import pytest
+
+
+@pytest.fixture(scope='module')
+def mock_database():
+    """Return MockDatabase object."""
+
+    class MockDatabase(Database):
+        """Mock database object to use in test cases."""
+
+        def __init__(self):
+            """Initialize mock database object. This class's method's shadow the
+            actual Database class methods.
+
+            `self.records` loads preexisting DB items.
+            `self.added_records` stores add record requests, with the
+            concept_id as the key and the complete record as the value.
+            `self.updates` stores update requests, with the concept_id as the
+            key and the updated attribute and new value as the value.
+            """
+            infile = PROJECT_ROOT / 'tests' / 'unit' / 'data' / 'diseases.json'
+            with open(infile, 'r') as f:
+                records_json = json.load(f)
+            self.records = {}
+            for record in records_json:
+                self.records[record['label_and_type']] = {
+                    record['concept_id']: record
+                }
+            self.added_records: Dict[str, Dict[Any, Any]] = {}
+            self.updates: Dict[str, Dict[Any, Any]] = {}
+            meta = PROJECT_ROOT / 'tests' / 'unit' / 'data' / 'metadata.json'
+            with open(meta, 'r') as f:
+                meta_json = json.load(f)
+            self.cached_sources = {}
+            for src in meta_json:
+                name = src['src_name']
+                self.cached_sources[name] = src
+                del self.cached_sources[name]['src_name']
+
+        def get_record_by_id(self, record_id: str,
+                             case_sensitive: bool = True,
+                             merge: bool = False) -> Optional[Dict]:
+            """Fetch record corresponding to provided concept ID.
+
+            :param str concept_id: concept ID for disease record
+            :param bool case_sensitive: if true, performs exact lookup, which
+                is more efficient. Otherwise, performs filter operation, which
+                doesn't require correct casing.
+            :param bool merge: if true, retrieve merged record
+            :return: complete record, if match is found; None otherwise
+            """
+            if merge:
+                label_and_type = f'{record_id.lower()}##merger'
+            else:
+                label_and_type = f'{record_id.lower()}##identity'
+            record_lookup = self.records.get(label_and_type, None)
+            if record_lookup:
+                if case_sensitive:
+                    record = record_lookup.get(record_id, None)
+                    if record:
+                        return record.copy()
+                    else:
+                        return None
+                elif record_lookup.values():
+                    return list(record_lookup.values())[0].copy()
+            return None
+
+        def get_records_by_type(self, query: str,
+                                match_type: str) -> List[Dict]:
+            """Retrieve records for given query and match type.
+
+            :param query: string to match against
+            :param str match_type: type of match to look for. Should be one
+                of "alias" or "label" (use get_record_by_id for
+                concept ID lookup)
+            :return: list of matching records. Empty if lookup fails.
+            """
+            assert match_type in ('alias', 'label')
+            label_and_type = f'{query}##{match_type.lower()}'
+            records_lookup = self.records.get(label_and_type, None)
+            if records_lookup:
+                return [v.copy() for v in records_lookup.values()]
+            else:
+                return []
+
+        def get_merged_record(self, merge_ref) -> Optional[Dict]:
+            """Fetch merged record from given reference.
+
+            :param str merge_ref: key for merged record, formated as a string
+                of grouped concept IDs separated by vertical bars, ending with
+                `##merger`. Must be correctly-cased.
+            :return: complete merged record if lookup successful, None
+                otherwise
+            """
+            record_lookup = self.records.get(merge_ref, None)
+            if record_lookup:
+                vals = list(record_lookup.values())
+                if vals:
+                    return vals[0].copy()
+            return None
+
+        def add_record(self, record: Dict, record_type: str):
+            """Store add record request sent to database.
+
+            :param Dict record: record (of any type) to upload. Must include
+                `concept_id` key. If record is of the `identity` type, the
+                concept_id must be correctly-cased.
+            :param str record_type: ignored by this function
+            """
+            self.added_records[record['concept_id']] = record
+
+        def update_record(self, concept_id: str, attribute: str,
+                          new_value: Any):
+            """Store update request sent to database.
+
+            :param str concept_id: record to update
+            :param str field: name of field to update
+            :parm str new_value: new value
+            """
+            assert f'{concept_id.lower()}##identity' in self.records
+            self.updates[concept_id] = {attribute: new_value}
+
+    return MockDatabase
diff --git a/tests/unit/data/diseases.json b/tests/unit/data/diseases.json
index 0d62cdbf..3da0ee88 100644
--- a/tests/unit/data/diseases.json
+++ b/tests/unit/data/diseases.json
@@ -1,6 +1,7 @@
 [
   {
     "label_and_type": "ncit:c2926##identity",
+    "item_type": "identity",
     "concept_id": "ncit:C2926",
     "label": "Lung Non-Small Cell Carcinoma",
     "aliases": [
@@ -16,61 +17,75 @@
       "NSCLC - Non-Small Cell Lung Cancer",
       "Non-Small Cell Lung Carcinoma"
     ],
-    "xrefs": ["umls:C0007131"],
+    "xrefs": [
+      "umls:C0007131"
+    ],
     "src_name": "NCIt"
   },
   {
     "label_and_type": "lung non-small cell carcinoma##label",
+    "item_type": "label",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "non-small cell cancer of the lung##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "nsclc##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "non-small cell carcinoma of the lung##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "non-small cell cancer of lung##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "non-small cell carcinoma of lung##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "non-small cell lung cancer##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "non small cell lung cancer nos##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "nsclc - non-small cell lung cancer##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "non-small cell lung carcinoma##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c2926",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "ncit:c3270##identity",
+    "src_name": "NCIt",
+    "item_type": "identity",
     "concept_id": "ncit:C3270",
     "label": "Neuroblastoma",
     "aliases": [
@@ -82,41 +97,50 @@
       "Neuroblastoma, NOS",
       "neuroblastoma"
     ],
-    "xrefs": ["umls:C0027819", "icd.o:9500/3"],
-    "src_name": "NCIt"
+    "xrefs": [
+      "umls:C0027819",
+      "icd.o:9500/3"
+    ]
   },
   {
     "label_and_type": "neuroblastoma##label",
+    "item_type": "label",
     "concept_id": "ncit:c3270",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "neural crest tumor, malignant##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c3270",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "neuroblastoma (nbl)##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c3270",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "neuroblastoma (schwannian stroma-poor)##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c3270",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "neuroblastoma, malignant##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c3270",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "neuroblastoma, nos##alias",
+    "item_type": "alias",
     "concept_id": "ncit:c3270",
     "src_name": "NCIt"
   },
   {
     "label_and_type": "mondo:0005072##identity",
+    "item_type": "identity",
     "concept_id": "mondo:0005072",
     "label": "neuroblastoma",
     "aliases": [
@@ -124,12 +148,14 @@
       "neuroblastoma (Schwannian Stroma-poor)",
       "neuroblastoma, malignant"
     ],
-    "other_identifiers": ["ncit:C3270"],
+    "other_identifiers": [
+      "DOID:769",
+      "ncit:C3270"
+    ],
     "xrefs": [
       "orphanet:635",
       "nifstd:birnlex_12631",
       "umls:C0027819",
-      "DOID:769",
       "gard:0007185",
       "meddra:10029260",
       "icd.o:9500/3",
@@ -143,26 +169,31 @@
   },
   {
     "label_and_type": "neuroblastoma##label",
+    "item_type": "label",
     "concept_id": "mondo:0005072",
     "src_name": "Mondo"
   },
   {
     "label_and_type": "neural crest tumor, malignant##alias",
+    "item_type": "alias",
     "concept_id": "mondo:0005072",
     "src_name": "Mondo"
   },
   {
     "label_and_type": "neuroblastoma (schwannian stroma-poor)##alias",
+    "item_type": "alias",
     "concept_id": "mondo:0005072",
     "src_name": "Mondo"
   },
   {
     "label_and_type": "neuroblastoma, malignant##alias",
+    "item_type": "alias",
     "concept_id": "mondo:0005072",
     "src_name": "Mondo"
   },
   {
     "label_and_type": "mondo:0002083##identity",
+    "item_type": "identity",
     "concept_id": "mondo:0002083",
     "label": "Richter syndrome",
     "aliases": [
@@ -170,74 +201,563 @@
       "Richter transformation",
       "Richter's transformation"
     ],
-    "other_identifiers": ["ncit:C35424"],
+    "other_identifiers": [
+      "ncit:C35424",
+      "DOID:1703"
+    ],
     "xrefs": [
       "icd:C91.1",
       "umls:C0349631",
-      "gard:0007578",
-      "DOID:1703"
+      "gard:0007578"
     ],
     "src_name": "Mondo"
   },
   {
     "label_and_type": "richter syndrome##label",
+    "item_type": "label",
     "concept_id": "mondo:0002083",
     "src_name": "Mondo"
   },
   {
     "label_and_type": "richter's syndrome##alias",
+    "item_type": "alias",
     "concept_id": "mondo:0002083",
     "src_name": "Mondo"
   },
   {
     "label_and_type": "richter transformation##alias",
+    "item_type": "alias",
     "concept_id": "mondo:0002083",
     "src_name": "Mondo"
   },
   {
     "label_and_type": "richter's transformation##alias",
+    "item_type": "alias",
     "concept_id": "mondo:0002083",
     "src_name": "Mondo"
   },
   {
-      "label_and_type": "mondo:0003587##identity",
-      "concept_id": "mondo:0003587",
-      "label": "pediatric liposarcoma",
-      "aliases": [
-          "childhood liposarcoma",
-          "liposarcoma"
-      ],
-      "other_identifiers": ["ncit:C8091"],
-      "xrefs": [
-          "DOID:5695",
-          "umls:C0279984"
-      ],
-      "pediatric": true,
-      "src_name": "Mondo"
+    "label_and_type": "mondo:0003587##identity",
+    "item_type": "identity",
+    "concept_id": "mondo:0003587",
+    "label": "pediatric liposarcoma",
+    "aliases": [
+      "childhood liposarcoma",
+      "liposarcoma"
+    ],
+    "pediatric_disease": true,
+    "other_identifiers": [
+      "DOID:5695",
+      "ncit:C8091"
+    ],
+    "xrefs": [
+      "umls:C0279984"
+    ],
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "pediatric liposarcoma##label",
+    "item_type": "label",
+    "concept_id": "mondo:0003587",
+    "src_name": "Mondo"
   },
   {
-      "label_and_type": "pediatric liposarcoma##label",
-      "concept_id": "mondo:0003587",
-      "src_name": "Mondo"
+    "label_and_type": "liposarcoma##alias",
+    "item_type": "alias",
+    "concept_id": "mondo:0003587",
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "childhood liposarcoma##alias",
+    "item_type": "alias",
+    "concept_id": "mondo:0003587",
+    "src_name": "Mondo"
   },
   {
-      "label_and_type": "mondo:0004099##identity",
-      "concept_id": "mondo:0004099",
-      "label": "adult cystic teratoma",
-      "aliases": ["cystic teratoma of adults"],
-      "other_identifiers": ["ncit:C9012"],
-      "xrefs": ["umls:C1368888", "DOID:7079"],
-      "pediatric": false,
-      "src_name": "Mondo"
+    "label_and_type": "mondo:0004099##identity",
+    "item_type": "identity",
+    "concept_id": "mondo:0004099",
+    "label": "adult cystic teratoma",
+    "aliases": [
+      "cystic teratoma of adults"
+    ],
+    "other_identifiers": [
+      "ncit:C9012",
+      "DOID:7079"
+    ],
+    "xrefs": [
+      "umls:C1368888"
+    ],
+    "src_name": "Mondo"
   },
   {
     "label_and_type": "adult cystic teratoma##label",
+    "item_type": "label",
     "concept_id": "mondo:0004099",
     "src_name": "Mondo"
   },
   {
-    "label_and_type": "cystic teratoma of adults##label",
+    "label_and_type": "cystic teratoma of adults##alias",
+    "item_type": "alias",
+    "item_type": "label",
     "concept_id": "mondo:0004099",
     "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "doid:769##identity",
+    "item_type": "identity",
+    "concept_id": "DOID:769",
+    "label": "neuroblastoma",
+    "other_identifiers": [
+      "ncit:C3270"
+    ],
+    "xrefs": [
+      "efo:0000621",
+      "gard:7185",
+      "icd.o:M9500/3",
+      "mesh:D009447",
+      "orphanet:635",
+      "umls:C0027819"
+    ],
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "neuroblastoma##label",
+    "item_type": "label",
+    "concept_id": "DOID:769",
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "doid:5695##identity",
+    "item_type": "identity",
+    "concept_id": "DOID:5695",
+    "label": "pediatric liposarcoma",
+    "other_identifiers": [
+      "ncit:C8091"
+    ],
+    "xrefs": [
+      "umls:C0279984"
+    ],
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "pediatric liposarcoma##label",
+    "item_type": "label",
+    "concept_id": "DOID:5695",
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "doid:1703##identity",
+    "item_type": "identity",
+    "concept_id": "DOID:1703",
+    "label": "Richter's syndrome",
+    "aliases": [
+      "Richter syndrome"
+    ],
+    "other_identifiers": [
+      "ncit:C35424"
+    ],
+    "xrefs": [
+      "umls:C0349631",
+      "gard:7578",
+      "icd10.cm:C91.1"
+    ],
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "richter's syndrome##label",
+    "item_type": "label",
+    "concept_id": "DOID:1703",
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "richter syndrome##alias",
+    "item_type": "alias",
+    "concept_id": "DOID:1703",
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "mondo:0005233##identity",
+    "item_type": "identity",
+    "concept_id": "mondo:0005233",
+    "label": "non-small cell lung carcinoma",
+    "aliases": [
+      "NSCLC - non-small cell lung cancer",
+      "non-small cell lung carcinoma",
+      "non-small cell carcinoma of lung",
+      "non-small cell carcinoma of the lung",
+      "non-small cell cancer of lung",
+      "non-small cell lung cancer",
+      "non-small cell cancer of the lung",
+      "NSCLC"
+    ],
+    "other_identifiers": [
+      "ncit:C2926",
+      "DOID:3908"
+    ],
+    "xrefs": [
+      "mesh:D002289",
+      "umls:C0007131",
+      "oncotree:NSCLC",
+      "efo:0003060",
+      "icd10:C34",
+      "kegg.disease:05223",
+      "HP:0030358"
+    ],
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "non-small cell lung carcinoma##label",
+    "item_type": "label",
+    "concept_id": "mondo:0005233",
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "nsclc - non-small cell lung cancer##alias",
+    "item_type": "alias",
+    "concept_id": "mondo:0005233",
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "non-small cell lung carcinoma##alias",
+    "item_type": "alias",
+    "concept_id": "mondo:0005233",
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "non-small cell carcinoma of lung##alias",
+    "item_type": "alias",
+    "concept_id": "mondo:0005233",
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "non-small cell carcinoma of the lung##alias",
+    "item_type": "alias",
+    "concept_id": "mondo:0005233",
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "non-small cell cancer of lung##alias",
+    "item_type": "alias",
+    "concept_id": "mondo:0005233",
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "non-small cell lung cancer##alias",
+    "item_type": "alias",
+    "concept_id": "mondo:0005233",
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "non-small cell cancer of the lung##alias",
+    "item_type": "alias",
+    "concept_id": "mondo:0005233",
+    "src_name": "Mondo"
+  },
+  {
+    "label_and_type": "ncit:c35424##identity",
+    "item_type": "identity",
+    "concept_id": "ncit:C35424",
+    "label": "Richter Syndrome",
+    "aliases": [
+      "Richter's Syndrome",
+      "Richter syndrome",
+      "Richter transformation",
+      "Richter's Transformation"
+    ],
+    "xrefs": [
+      "umls:C0349631"
+    ],
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "richter syndrome##label",
+    "item_type": "label",
+    "concept_id": "ncit:C35424",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "richter's syndrome##alias",
+    "item_type": "alias",
+    "concept_id": "ncit:C35424",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "richter syndrome##alias",
+    "item_type": "alias",
+    "concept_id": "ncit:C35424",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "richter transformation##alias",
+    "item_type": "alias",
+    "concept_id": "ncit:C35424",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "richter's transformation##alias",
+    "item_type": "alias",
+    "concept_id": "ncit:C35424",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "ncit:c8091##identity",
+    "item_type": "identity",
+    "label": "Childhood Liposarcoma",
+    "concept_id": "ncit:C8091",
+    "aliases": [
+      "Liposarcoma",
+      "Pediatric Liposarcoma"
+    ],
+    "xrefs": [
+      "umls:C0279984"
+    ],
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "childhood liposarcoma##label",
+    "item_type": "label",
+    "concept_id": "ncit:C8091",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "liposarcoma##alias",
+    "item_type": "alias",
+    "concept_id": "ncit:C8091",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "peidatric liposarcoma##alias",
+    "item_type": "alias",
+    "concept_id": "ncit:C8091",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "ncit:c9012##identity",
+    "item_type": "identity",
+    "label": "Adult Cystic Teratoma",
+    "concept_id": "ncit:C9012",
+    "aliases": [
+      "Adult cystic teratoma"
+    ],
+    "xrefs": [
+      "icd.o:9080/0"
+    ],
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "adult cystic teratoma##label",
+    "item_type": "label",
+    "concept_id": "ncit:C9012",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "adult cystic teratoma##alias",
+    "item_type": "alias",
+    "concept_id": "ncit:C9012",
+    "src_name": "NCIt"
+  },
+  {
+    "label_and_type": "doid:7079##identity",
+    "item_type": "identity",
+    "label": "adult cystic teratoma",
+    "concept_id": "DOID:7079",
+    "other_identifiers": [
+      "ncit:C9012"
+    ],
+    "xrefs": [
+      "umls:C1368888"
+    ],
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "adult cystic teratoma##label",
+    "item_type": "label",
+    "concept_id": "DOID:769",
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "doid:3908##identity",
+    "item_type": "identity",
+    "label": "lung non-small cell carcinoma",
+    "concept_id": "DOID:3908",
+    "other_identifiers": [
+      "ncit:C2926"
+    ],
+    "xrefs": [
+      "umls:C0007131",
+      "kegg.disease:05223",
+      "efo:0003060",
+      "mesh:D002289"
+    ],
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "lung non-small cell carcinoma##label",
+    "item_type": "label",
+    "concept_id": "doid:3908",
+    "src_name": "DO"
+  },
+  {
+    "label_and_type": "ncit:c3270|mondo:0005072|doid:769##merger",
+    "concept_id": "ncit:C3270|mondo:0005072|DOID:769##merger",
+    "label": "Neuroblastoma",
+    "aliases": [
+      "neuroblastoma",
+      "Neural Crest Tumor, Malignant",
+      "neuroblastoma (Schwannian Stroma-poor)",
+      "Neuroblastoma (Schwannian Stroma-poor)",
+      "Neuroblastoma (Schwannian Stroma-Poor)",
+      "Neuroblastoma, NOS",
+      "NEUROBLASTOMA, MALIGNANT",
+      "Neuroblastoma (NBL)",
+      "neural Crest tumor, malignant",
+      "neuroblastoma, malignant"
+    ],
+    "xrefs": [
+      "umls:C0027819",
+      "icd.o:9500/3",
+      "efo:0000621",
+      "gard:0007185",
+      "gard:7185",
+      "icd:C74.9",
+      "icd.o:9500/3",
+      "icd.o:M9500/3",
+      "mesh:D009447",
+      "meddra:10029260",
+      "nifstd:birnlex_12631",
+      "oncotree:NBL",
+      "orphanet:635",
+      "umls:CN205405"
+    ],
+    "pediatric": null
+  },
+  {
+    "label_and_type": "ncit:c2926|mondo:0005233|doid:3908##merger",
+    "concept_id": "ncit:C2926|mondo:0005233|DOID:3908",
+    "label": "Lung Non-Small Cell Carcinoma",
+    "aliases": [
+      "Non Small Cell Lung Cancer NOS",
+      "Non-Small Cell Lung Cancer",
+      "Non-Small Cell Cancer of the Lung",
+      "NSCLC",
+      "non-small cell lung cancer",
+      "Non-Small Cell Carcinoma of the Lung",
+      "Non-Small Cell Cancer of Lung",
+      "Non-small cell lung cancer, NOS",
+      "Non-Small Cell Carcinoma of Lung",
+      "NSCLC - Non-Small Cell Lung Cancer",
+      "Non-Small Cell Lung Carcinoma",
+      "NSCLC - non-small cell lung cancer",
+      "non-small cell lung carcinoma",
+      "non-small cell carcinoma of lung",
+      "non-small cell carcinoma of the lung",
+      "non-small cell cancer of lung",
+      "non-small cell cancer of the lung"
+    ],
+    "xrefs": [
+      "umls:C0007131",
+      "mesh:D002289",
+      "umls:C0007131",
+      "oncotree:NSCLC",
+      "efo:0003060",
+      "icd10:C34",
+      "kegg.disease:05223",
+      "HP:0030358"
+    ],
+    "item_type": "merger"
+  },
+  {
+    "label_and_type": "ncit:c35424|mondo:0002083|doid:1703##merger",
+    "concept_id": "ncit:C35424|mondo:0002083|DOID:1703",
+    "label": "Richter Syndrome",
+    "aliases": [
+      "Richter's Syndrome",
+      "Richter syndrome",
+      "Richter transformation",
+      "Richter's Transformation",
+      "Richter's syndrome",
+      "Richter's transformation"
+    ],
+    "xrefs": [
+      "umls:C0349631",
+      "icd:C91.1",
+      "gard:0007578",
+      "gard:7578",
+      "icd10.cm:C91.1"
+    ],
+    "item_type": "merger"
+  },
+  {
+    "label_and_type": "ncit:c8091|mondo:0003587|doid:5695##merger",
+    "concept_id": "ncit:C8091|mondo:0003587|DOID:5695",
+    "label": "Childhood Liposarcoma",
+    "aliases": [
+      "Liposarcoma",
+      "Pediatric Liposarcoma",
+      "childhood liposarcoma",
+      "liposarcoma"
+    ],
+    "xrefs": [
+      "umls:C0279984"
+    ],
+    "pediatric_disease": true,
+    "item_type": "merger"
+  },
+  {
+    "label_and_type": "ncit:c9012|mondo:0004099|doid:7079##merger",
+    "concept_id": "ncit:C9012|mondo:0004099|DOID:7079",
+    "label": "Adult Cystic Teratoma",
+    "aliases": [
+      "Adult cystic teratoma",
+      "cystic teratoma of adults"
+    ],
+    "xrefs": [
+      "icd.o:9080/0",
+      "umls:C1368888"
+    ],
+    "item_type": "merger"
+  },
+  {
+    "label_and_type": "mondo:0010648##identity",
+    "item_type": "identity",
+    "src_name": "Mondo",
+    "label": "major affective disorder 2",
+    "concept_id": "mondo:0010648",
+    "aliases": [
+      "MAFD2"
+    ],
+    "other_identifiers": [
+      "DOID:0080221"
+    ],
+    "xrefs": [
+      "mesh:C564108",
+      "omim:309200"
+    ]
+  },
+  {
+    "label_and_type": "major affective disorder 2##label",
+    "item_type": "label",
+    "src_name": "Mondo",
+    "concept_id": "mondo:0010648"
+  },
+  {
+    "label_and_type": "MAFD2",
+    "item_type": "label",
+    "src_name": "Mondo",
+    "concept_id": "mondo:0010648"
+  },
+  {
+    "label_and_type": "mondo:0010648##merger",
+    "item_type": "merger",
+    "concept_id": "mondo:0010648",
+    "label": "major affective disorder 2",
+    "aliases": [
+      "MAFD2"
+    ],
+    "xrefs": [
+      "mesh:C564108",
+      "omim:309200"
+    ]
   }
 ]
diff --git a/tests/unit/data/metadata.json b/tests/unit/data/metadata.json
index 383c956e..3e69277c 100644
--- a/tests/unit/data/metadata.json
+++ b/tests/unit/data/metadata.json
@@ -24,5 +24,18 @@
       "share_alike": false,
       "attribution": true
     }
+  },
+  {
+    "src_name": "DO",
+    "data_license": "CC0 1.0",
+    "data_license_url": "https://creativecommons.org/publicdomain/zero/1.0/legalcode",
+    "version": "20210219",
+    "data_url": "http://purl.obolibrary.org/obo/doid.owl",
+    "rdp_url": null,
+    "data_license_attributes": {
+      "non_commercial": false,
+      "share_alike": false,
+      "attribution": false
+    }
   }
 ]
diff --git a/tests/unit/test_do.py b/tests/unit/test_do.py
new file mode 100644
index 00000000..583196d7
--- /dev/null
+++ b/tests/unit/test_do.py
@@ -0,0 +1,135 @@
+"""Test retrieval of Disease Ontology source data."""
+import pytest
+from disease.schemas import MatchType
+from disease.query import QueryHandler
+from typing import Dict
+
+
+@pytest.fixture(scope='module')
+def do():
+    """Build NCIt ETL test fixture."""
+    class QueryGetter:
+
+        def __init__(self):
+            self.query_handler = QueryHandler()
+
+        def search(self, query_str):
+            response = self.query_handler.search_sources(query_str, keyed=True,
+                                                         incl='do')
+            return response['source_matches']['DO']
+    return QueryGetter()
+
+
+@pytest.fixture(scope='module')
+def neuroblastoma():
+    """Build neuroblastoma fixture."""
+    return {
+        "concept_id": "DOID:769",
+        "label": "neuroblastoma",
+        "other_identifiers": [
+            "ncit:C3270"
+        ],
+        "xrefs": [
+            "efo:0000621",
+            "gard:7185",
+            "icd.o:M9500/3",
+            "mesh:D009447",
+            "orphanet:635",
+            "umls:C0027819",
+        ],
+        "aliases": []
+    }
+
+
+@pytest.fixture(scope='module')
+def pediatric_liposarcoma():
+    """Create test fixture for pediatric liposarcoma."""
+    return {
+        "concept_id": "DOID:5695",
+        "label": "pediatric liposarcoma",
+        "other_identifiers": ["ncit:C8091"],
+        "xrefs": ["umls:C0279984"],
+        "aliases": []
+    }
+
+
+@pytest.fixture(scope='module')
+def richter():
+    """Create test fixture for Richter's Syndrome."""
+    return {
+        "concept_id": "DOID:1703",
+        "label": "Richter's syndrome",
+        "aliases": ["Richter syndrome"],
+        "other_identifiers": ["ncit:C35424"],
+        "xrefs": ["umls:C0349631", "gard:7578", "icd10.cm:C91.1"]
+    }
+
+
+def compare_records(actual_record: Dict, fixture_record: Dict):
+    """Check that identity records are identical."""
+    assert actual_record['concept_id'] == fixture_record['concept_id']
+    assert ('label' in actual_record) == ('label' in fixture_record)
+    if 'label' in actual_record or 'label' in fixture_record:
+        assert actual_record['label'] == fixture_record['label']
+    assert ('aliases' in actual_record) == ('aliases' in fixture_record)
+    if 'aliases' in actual_record or 'aliases' in fixture_record:
+        assert set(actual_record['aliases']) == set(fixture_record['aliases'])
+    assert ('other_identifiers' in actual_record) == ('other_identifiers' in fixture_record)  # noqa: E501
+    if 'other_identifiers' in actual_record or 'other_identifiers' in fixture_record:  # noqa: E501
+        assert set(actual_record['other_identifiers']) == set(fixture_record['other_identifiers'])  # noqa: E501
+    assert ('xrefs' in actual_record) == ('xrefs' in fixture_record)
+    if 'xrefs' in actual_record or 'xrefs' in fixture_record:
+        assert set(actual_record['xrefs']) == set(fixture_record['xrefs'])
+
+
+def test_concept_id_match(do, neuroblastoma, pediatric_liposarcoma, richter):
+    """Test that concept ID search resolves to correct record"""
+    import boto3
+    from boto3.dynamodb.conditions import Key
+    response = boto3.resource('dynamodb', endpoint_url="http://localhost:8000").Table('disease_concepts').query(KeyConditionExpression=Key('label_and_type').eq('doid:769##identity'))['Items'][0]  # noqa: E501
+    print(response)
+    response = do.search('DOID:769')
+    print(response)
+    assert response['match_type'] == MatchType.CONCEPT_ID
+    assert len(response['records']) == 1
+    actual_disease = response['records'][0].dict()
+    compare_records(actual_disease, neuroblastoma)
+
+    response = do.search('doid:5695')
+    assert response['match_type'] == MatchType.CONCEPT_ID
+    assert len(response['records']) == 1
+    actual_disease = response['records'][0].dict()
+    compare_records(actual_disease, pediatric_liposarcoma)
+
+    response = do.search('DOid:1703')
+    assert response['match_type'] == MatchType.CONCEPT_ID
+    assert len(response['records']) == 1
+    actual_disease = response['records'][0].dict()
+    compare_records(actual_disease, richter)
+
+    response = do.search('5695')
+    assert response['match_type'] == MatchType.NO_MATCH
+
+
+def test_label_match(do, neuroblastoma, pediatric_liposarcoma):
+    """Test that label searches resolve to correct records."""
+    response = do.search('pediatric liposarcoma')
+    assert response['match_type'] == MatchType.LABEL
+    assert len(response['records']) == 1
+    actual_disease = response['records'][0].dict()
+    compare_records(actual_disease, pediatric_liposarcoma)
+
+    response = do.search('NEUROBLASTOMA')
+    assert response['match_type'] == MatchType.LABEL
+    assert len(response['records']) == 1
+    actual_disease = response['records'][0].dict()
+    compare_records(actual_disease, neuroblastoma)
+
+
+def test_alias_match(do, richter):
+    """Test that alias searches resolve to correct records."""
+    response = do.search('Richter Syndrome')
+    assert response['match_type'] == MatchType.ALIAS
+    assert len(response['records']) == 1
+    actual_disease = response['records'][0].dict()
+    compare_records(actual_disease, richter)
diff --git a/tests/unit/test_merge.py b/tests/unit/test_merge.py
new file mode 100644
index 00000000..fd1ace5c
--- /dev/null
+++ b/tests/unit/test_merge.py
@@ -0,0 +1,308 @@
+"""Test merged record generation."""
+import pytest
+from disease.etl.merge import Merge
+from typing import Dict
+
+
+@pytest.fixture(scope='module')
+def merge_handler(mock_database):
+    """Provide Merge instance to test cases."""
+    class MergeHandler():
+        def __init__(self):
+            self.merge = Merge(mock_database())
+
+        def get_merge(self):
+            return self.merge
+
+        def create_merged_concepts(self, record_ids):
+            return self.merge.create_merged_concepts(record_ids)
+
+        def get_added_records(self):
+            return self.merge._database.added_records
+
+        def get_updates(self):
+            return self.merge._database.updates
+
+        def generate_merged_record(self, record_id_set):
+            return self.merge._generate_merged_record(record_id_set)
+
+    return MergeHandler()
+
+
+def compare_merged_records(actual_record: Dict, fixture_record: Dict):
+    """Check that records are identical."""
+    assert actual_record['concept_id'] == fixture_record['concept_id']
+    assert actual_record['label_and_type'] == fixture_record['label_and_type']
+    assert ('label' in actual_record) == ('label' in fixture_record)
+    if 'label' in actual_record or 'label' in fixture_record:
+        assert actual_record['label'] == fixture_record['label']
+    assert ('aliases' in actual_record) == ('aliases' in fixture_record)
+    if 'aliases' in actual_record or 'aliases' in fixture_record:
+        assert set(actual_record['aliases']) == set(fixture_record['aliases'])
+    assert ('xrefs' in actual_record) == ('xrefs' in fixture_record)
+    if 'xrefs' in actual_record or 'xrefs' in fixture_record:
+        assert set(actual_record['xrefs']) == set(fixture_record['xrefs'])
+    assert ('pediatric_disease' in actual_record) == \
+        ('pediatric_disease' in fixture_record)
+    if 'pediatric_disease' in actual_record or \
+            'pediatric_disease' in fixture_record:
+        assert actual_record['pediatric_disease'] == \
+            fixture_record['pediatric_disease']
+
+
+@pytest.fixture(scope='module')
+def neuroblastoma():
+    """Create neuroblastoma fixture."""
+    return {
+        "label_and_type": "ncit:c3270|mondo:0005072|doid:769##merger",
+        "concept_id": "ncit:C3270|mondo:0005072|DOID:769",
+        "label": "Neuroblastoma",
+        "aliases": [
+            "neuroblastoma",
+            "Neural Crest Tumor, Malignant",
+            "Neuroblastoma (Schwannian Stroma-poor)",
+            "neuroblastoma (Schwannian Stroma-poor)",
+            "Neuroblastoma (Schwannian Stroma-Poor)",
+            "Neuroblastoma, NOS",
+            "NEUROBLASTOMA, MALIGNANT",
+            "Neuroblastoma (NBL)",
+            "neural Crest tumor, malignant",
+            "neuroblastoma, malignant"
+        ],
+        "xrefs": [
+            "umls:C0027819",
+            "icd.o:9500/3",
+            "efo:0000621",
+            "gard:7185",
+            "gard:0007185",
+            "icd:C74.9",
+            "icd.o:9500/3",
+            "icd.o:M9500/3",
+            "mesh:D009447",
+            "meddra:10029260",
+            "nifstd:birnlex_12631",
+            "oncotree:NBL",
+            "orphanet:635",
+            "umls:CN205405"
+        ],
+        "pediatric": None
+    }
+
+
+@pytest.fixture(scope='module')
+def lnscc():
+    """Create lung non small cell carcinoma fixture"""
+    return {
+        "label_and_type": "ncit:c2926|mondo:0005233|doid:3908##merger",
+        "concept_id": "ncit:C2926|mondo:0005233|DOID:3908",
+        "label": "Lung Non-Small Cell Carcinoma",
+        "aliases": [
+            "Non Small Cell Lung Cancer NOS",
+            "Non-Small Cell Lung Cancer",
+            "Non-Small Cell Cancer of the Lung",
+            "NSCLC",
+            "non-small cell lung cancer",
+            "Non-Small Cell Carcinoma of the Lung",
+            "Non-Small Cell Cancer of Lung",
+            "Non-small cell lung cancer, NOS",
+            "Non-Small Cell Carcinoma of Lung",
+            "NSCLC - Non-Small Cell Lung Cancer",
+            "Non-Small Cell Lung Carcinoma",
+            "NSCLC - non-small cell lung cancer",
+            "non-small cell lung carcinoma",
+            "non-small cell carcinoma of lung",
+            "non-small cell carcinoma of the lung",
+            "non-small cell cancer of lung",
+            "non-small cell cancer of the lung",
+        ],
+        "xrefs": [
+            "umls:C0007131",
+            "mesh:D002289",
+            "umls:C0007131",
+            "oncotree:NSCLC",
+            "efo:0003060",
+            "icd10:C34",
+            "kegg.disease:05223",
+            "HP:0030358"
+        ],
+        "item_type": "merger"
+    }
+
+
+@pytest.fixture(scope='module')
+def richter():
+    """Create Richter Syndrome fixture"""
+    return {
+        "label_and_type": "ncit:c35424|mondo:0002083|doid:1703##merger",
+        "concept_id": "ncit:C35424|mondo:0002083|DOID:1703",
+        "label": "Richter Syndrome",
+        "aliases": [
+            "Richter's Syndrome",
+            "Richter syndrome",
+            "Richter transformation",
+            "Richter's Transformation",
+            "Richter's syndrome",
+            "Richter's transformation"
+        ],
+        "xrefs": [
+            "umls:C0349631",
+            "icd:C91.1",
+            "gard:0007578",
+            "gard:7578",
+            "icd10.cm:C91.1"
+        ],
+        "item_type": "merger"
+    }
+
+
+@pytest.fixture(scope='module')
+def ped_liposarcoma():
+    """Create pediatric liposarcoma fixture."""
+    return {
+        "label_and_type": "ncit:c8091|mondo:0003587|doid:5695##merger",
+        "concept_id": "ncit:C8091|mondo:0003587|DOID:5695",
+        "label": "Childhood Liposarcoma",
+        "aliases": [
+            "Liposarcoma",
+            "Pediatric Liposarcoma",
+            "childhood liposarcoma",
+            "liposarcoma"
+        ],
+        "xrefs": ["umls:C0279984"],
+        "pediatric_disease": True,
+        "item_type": "merger"
+    }
+
+
+@pytest.fixture(scope='module')
+def teratoma():
+    """Create fixture for adult cystic teratoma."""
+    return {
+        "label_and_type": "ncit:c9012|mondo:0004099|doid:7079##merger",
+        "concept_id": "ncit:C9012|mondo:0004099|DOID:7079",
+        "label": "Adult Cystic Teratoma",
+        "aliases": ["Adult cystic teratoma", "cystic teratoma of adults"],
+        "xrefs": ["icd.o:9080/0", "umls:C1368888"],
+        "item_type": "merger",
+    }
+
+
+@pytest.fixture(scope='module')
+def mafd2():
+    """Create a fixture for major affective disorder 2. Tests whether a
+    deprecated DO reference is filtered out.
+    """
+    return {
+        "label_and_type": "mondo:0010648##merger",
+        "item_type": "merger",
+        "concept_id": "mondo:0010648",
+        "label": "major affective disorder 2",
+        "aliases": [
+            "MAFD2"
+        ],
+        "xrefs": [
+            "mesh:C564108",
+            "omim:309200"
+        ]
+    }
+
+
+@pytest.fixture(scope='module')
+def record_id_groups():
+    """Fixture for concept ID group input."""
+    return {
+        'neuroblastoma': [
+            "ncit:C3270", "mondo:0005072", "DOID:769"
+        ],
+        'lnscc': [
+            "ncit:C2926", "mondo:0005233", "DOID:3908"
+        ],
+        'richter': [
+            "ncit:C35424", "mondo:0002083", "DOID:1703"
+        ],
+        'ped_liposarcoma': [
+            "ncit:C8091", "mondo:0003587", "DOID:5695"
+        ],
+        'teratoma': [
+            "ncit:C9012", "mondo:0004099", "DOID:7079"
+        ],
+        'mafd2': ["mondo:0010648", "DOID:0080221"]
+    }
+
+
+def test_generate_merged_record(merge_handler, record_id_groups, neuroblastoma,
+                                lnscc, richter, ped_liposarcoma, teratoma,
+                                mafd2):
+    """Test generation of individual merged record."""
+    neuroblastoma_ids = record_id_groups['neuroblastoma']
+    response, r_ids = merge_handler.generate_merged_record(neuroblastoma_ids)
+    assert set(r_ids) == set(neuroblastoma_ids)
+    compare_merged_records(response, neuroblastoma)
+
+    lnscc_ids = record_id_groups['lnscc']
+    response, r_ids = merge_handler.generate_merged_record(lnscc_ids)
+    assert set(r_ids) == set(lnscc_ids)
+    compare_merged_records(response, lnscc)
+
+    richter_ids = record_id_groups['richter']
+    response, r_ids = merge_handler.generate_merged_record(richter_ids)
+    assert set(r_ids) == set(richter_ids)
+    compare_merged_records(response, richter)
+
+    ped_liposarcoma_ids = record_id_groups['ped_liposarcoma']
+    response, r_ids = merge_handler.generate_merged_record(ped_liposarcoma_ids)
+    assert set(r_ids) == set(ped_liposarcoma_ids)
+    compare_merged_records(response, ped_liposarcoma)
+
+    teratoma_ids = record_id_groups['teratoma']
+    response, r_ids = merge_handler.generate_merged_record(teratoma_ids)
+    assert set(r_ids) == set(teratoma_ids)
+    compare_merged_records(response, teratoma)
+
+    mafd2_ids = record_id_groups['mafd2']
+    response, r_ids = merge_handler.generate_merged_record(mafd2_ids)
+    assert set(r_ids) == {"mondo:0010648"}
+    compare_merged_records(response, mafd2)
+
+
+def test_create_merged_concepts(merge_handler, record_id_groups, neuroblastoma,
+                                lnscc, richter, ped_liposarcoma, teratoma,
+                                mafd2):
+    """Test end-to-end creation and upload of merged concepts."""
+    mondo_ids = [
+        "mondo:0005072",
+        "mondo:0005233",
+        "mondo:0002083",
+        "mondo:0004099",
+        "mondo:0003587",
+        "mondo:0010648"
+    ]
+    merge_handler.create_merged_concepts(mondo_ids)
+
+    # check merged record generation and storage
+    added_records = merge_handler.get_added_records()
+    assert len(added_records) == 6
+    neuroblastoma_id = neuroblastoma['concept_id']
+    assert neuroblastoma_id in added_records
+    compare_merged_records(added_records[neuroblastoma_id],
+                           neuroblastoma)
+    lnscc_id = lnscc['concept_id']
+    assert lnscc_id in added_records
+    compare_merged_records(added_records[lnscc_id],
+                           lnscc)
+    richter_id = richter['concept_id']
+    assert richter_id in added_records
+    compare_merged_records(added_records[richter_id],
+                           richter)
+    ped_liposarcoma_id = ped_liposarcoma['concept_id']
+    assert ped_liposarcoma_id in added_records
+    compare_merged_records(added_records[ped_liposarcoma_id],
+                           ped_liposarcoma)
+    teratoma_id = teratoma['concept_id']
+    assert teratoma_id in added_records
+    compare_merged_records(added_records[teratoma_id],
+                           teratoma)
+    mafd2_id = mafd2['concept_id']
+    assert mafd2_id in added_records
+    compare_merged_records(added_records[mafd2_id],
+                           mafd2)
diff --git a/tests/unit/test_mondo.py b/tests/unit/test_mondo.py
index f1d17178..5ae031f4 100644
--- a/tests/unit/test_mondo.py
+++ b/tests/unit/test_mondo.py
@@ -31,12 +31,14 @@ def neuroblastoma():
             "neuroblastoma (Schwannian Stroma-poor)",
             "neuroblastoma, malignant"
         ],
-        "other_identifiers": ["ncit:C3270"],
+        "other_identifiers": [
+            "ncit:C3270",
+            "DOID:769",
+        ],
         "xrefs": [
             "orphanet:635",
             "nifstd:birnlex_12631",
             "umls:C0027819",
-            "DOID:769",
             "gard:0007185",
             "meddra:10029260",
             "icd.o:9500/3",
@@ -46,7 +48,7 @@ def neuroblastoma():
             "oncotree:NBL",
             "mesh:D009447"
         ],
-        "pediatric": None,
+        "pediatric_disease": None,
     }
 
 
@@ -61,14 +63,13 @@ def richter_syndrome():
             "Richter transformation",
             "Richter's transformation"
         ],
-        "other_identifiers": ["ncit:C35424"],
+        "other_identifiers": ["ncit:C35424", "DOID:1703"],
         "xrefs": [
             "icd:C91.1",
             "umls:C0349631",
             "gard:0007578",
-            "DOID:1703"
         ],
-        "pediatric": None,
+        "pediatric_disease": None,
     }
 
 
@@ -84,12 +85,11 @@ def pediatric_liposarcoma():
             "childhood liposarcoma",
             "liposarcoma"
         ],
-        "other_identifiers": ["ncit:C8091"],
+        "other_identifiers": ["DOID:5695", "ncit:C8091"],
         "xrefs": [
-            "DOID:5695",
             "umls:C0279984"
         ],
-        "pediatric": True,
+        "pediatric_disease": True,
     }
 
 
@@ -102,9 +102,9 @@ def cystic_teratoma_adult():
         "concept_id": "mondo:0004099",
         "label": "adult cystic teratoma",
         "aliases": ["cystic teratoma of adults"],
-        "other_identifiers": ["ncit:C9012"],
-        "xrefs": ["umls:C1368888", "DOID:7079"],
-        "pediatric": False,
+        "pediatric_disease": None,
+        "other_identifiers": ["ncit:C9012", "DOID:7079"],
+        "xrefs": ["umls:C1368888"],
     }
 
 
@@ -123,7 +123,8 @@ def compare_records(actual_record: Dict, fixture_record: Dict):
     assert ('xrefs' in actual_record) == ('xrefs' in fixture_record)
     if 'xrefs' in actual_record or 'xrefs' in fixture_record:
         assert set(actual_record['xrefs']) == set(fixture_record['xrefs'])
-    assert actual_record['pediatric'] is fixture_record['pediatric']
+    assert actual_record['pediatric_disease'] is \
+        fixture_record['pediatric_disease']
 
 
 def test_concept_id_match(mondo, neuroblastoma, richter_syndrome,
diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py
index d80ed786..edea2eb8 100644
--- a/tests/unit/test_query.py
+++ b/tests/unit/test_query.py
@@ -26,7 +26,7 @@ def test_query(query_handler):
     assert resp['query'] == 'Neuroblastoma'
     matches = resp['source_matches']
     assert isinstance(matches, list)
-    assert len(matches) == 2
+    assert len(matches) == 3
     ncit = list(filter(lambda m: m['source'] == 'NCIt',
                        matches))[0]
     assert len(ncit['records']) == 1
@@ -47,13 +47,14 @@ def test_query_keyed(query_handler):
 def test_query_specify_query_handlers(query_handler):
     """Test inclusion and exclusion of sources in query."""
     # test full inclusion
-    sources = 'ncit,mondo'
+    sources = 'ncit,mondo,do'
     resp = query_handler.normalize('Neuroblastoma', keyed=True,
                                    incl=sources, excl='')
     matches = resp['source_matches']
-    assert len(matches) == 2
+    assert len(matches) == 3
     assert 'NCIt' in matches
     assert 'Mondo' in matches
+    assert 'DO' in matches
 
     # test full exclusion
     resp = query_handler.normalize('Neuroblastoma', keyed=True,