/**
* Copyright (c) 2015 Lemur Consulting Ltd.
* <p/>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p/>
* http://www.apache.org/licenses/LICENSE-2.0
* <p/>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package uk.co.flax.biosolr.ontology.search.solr;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.co.flax.biosolr.ontology.api.AccumulatedFacetEntry;
import uk.co.flax.biosolr.ontology.api.FacetEntry;
import uk.co.flax.biosolr.ontology.api.OntologyEntryBean;
import uk.co.flax.biosolr.ontology.search.OntologySearch;
import uk.co.flax.biosolr.ontology.search.ResultsList;
import uk.co.flax.biosolr.ontology.search.SearchEngineException;
/**
* Utility class to convert a list of Document EFO annotation facets into a
* hierarchical tree.
*
* <p>This uses the ontology index to build a full tree of nodes which have
* descendants contained in the facet list. Note that there are no uniqueness checks
* done on the leaf nodes - if a node has multiple parents, it may appear more than
* once, and affect the accumulated total counts accordingly.</p>
*
* @author Matt Pearce
*/
public class OntologyFacetTreeBuilder implements FacetTreeBuilder {
private static final Logger LOGGER = LoggerFactory.getLogger(OntologyFacetTreeBuilder.class);
private final OntologySearch ontologySearch;
public OntologyFacetTreeBuilder(OntologySearch ontologySearch) {
this.ontologySearch = ontologySearch;
}
/**
* Convert the incoming facet entries into a hierarchical facet tree, starting
* from the highest parent node common to all of the facets, and terminating at
* the lowest leaf level for each facet.
* @param entries the annotation facets, expected to be a list of Ontology URIs.
* @return a list containing one or more {@link AccumulatedFacetEntry} items representing
* the full tree.
*/
@Override
public List<FacetEntry> buildFacetTree(List<FacetEntry> entries) {
// Look up ontology entries for every facet in the entry set.
Map<String, OntologyEntryBean> annotationMap = lookupOntologyEntriesByFacetLabel(entries);
Map<String, FacetEntry> entryMap = convertEntriesToMap(entries);
// Look up ontology entries for all parent nodes common to the incoming entries
Set<String> parentUris = extractParentUris(annotationMap.values());
// Remove URIs for which we already have EFO annotations
parentUris.removeAll(annotationMap.keySet());
// Add the parent ontology entries to the facet ontology entries
annotationMap.putAll(lookupOntologyEntriesByUri(parentUris));
// Build a map of annotations by level in the tree, so we can start at the highest level
Map<Integer, List<OntologyEntryBean>> levelMap = collateAnnotationsByLevel(annotationMap);
SortedSet<FacetEntry> facets = new TreeSet<>(Collections.reverseOrder());
// Take the first entry (or entries) in the level map, and build the accumulated entries
Iterator<Integer> levelIter = levelMap.keySet().iterator();
if (levelIter.hasNext()) {
Integer level = levelIter.next();
LOGGER.debug("Building AccumulatedFacetEntry at level {}", level);
for (OntologyEntryBean anno : levelMap.get(level)) {
FacetEntry fe = buildAccumulatedEntryTree(level, anno, entryMap, annotationMap);
facets.add(fe);
}
}
return new ArrayList<FacetEntry>(facets);
}
/**
* Recursively build an accumulated facet entry tree.
* @param level current level in the tree (used for debugging/logging).
* @param node the current node.
* @param entryMap the facet entry map.
* @param annotationMap the map of valid annotations (either in the facet map, or parents of
* entries in the facet map).
* @return an {@link AccumulatedFacetEntry} containing details for the current node and all
* sub-nodes down to the lowest leaf which has a facet count.
*/
private AccumulatedFacetEntry buildAccumulatedEntryTree(int level, OntologyEntryBean node,
Map<String, FacetEntry> entryMap, Map<String, OntologyEntryBean> annotationMap) {
SortedSet<AccumulatedFacetEntry> childHierarchy = new TreeSet<>(Collections.reverseOrder());
long childTotal = 0;
if (node.getChildUris() != null) {
for (String childUri : node.getChildUris()) {
if (annotationMap.containsKey(childUri)) {
LOGGER.trace("[{}] Building subAfe for {}", level, childUri);
AccumulatedFacetEntry subAfe = buildAccumulatedEntryTree(level + 1, annotationMap.get(childUri),
entryMap, annotationMap);
childTotal += subAfe.getTotalCount();
childHierarchy.add(subAfe);
LOGGER.trace("[{}] subAfe total: {} - child Total {}, child count {}", level, subAfe.getTotalCount(), childTotal, childHierarchy.size());
}
}
}
long count = 0;
if (entryMap.containsKey(node.getUri())) {
count = entryMap.get(node.getUri()).getCount();
}
String label;
if (node.getLabel() == null) {
label = node.getShortForm().get(0);
} else {
label = node.getLabel().get(0);
}
LOGGER.trace("[{}] Building AFE for {}", level, node.getUri());
return new AccumulatedFacetEntry(node.getUri(), label, count, childTotal, childHierarchy);
}
/**
* Convenience method to create a URI-keyed map of facet entries.
* @param entries
* @return a map of URI : FacetEntry.
*/
private Map<String, FacetEntry> convertEntriesToMap(List<FacetEntry> entries) {
Map<String, FacetEntry> entryMap = new HashMap<>();
for (FacetEntry entry : entries) {
entryMap.put(entry.getLabel(), entry);
}
return entryMap;
}
/**
* Fetch the EFO annotations for a list of facet entries.
* @param entries
* @return a map of URI -> ontology entry for the facet entries.
*/
private Map<String, OntologyEntryBean> lookupOntologyEntriesByFacetLabel(List<FacetEntry> entries) {
List<String> uris = new ArrayList<>(entries.size());
for (FacetEntry entry : entries) {
uris.add(entry.getLabel());
}
return lookupOntologyEntriesByUri(uris);
}
/**
* Fetch the EFO annotations for a collection of URIs.
* @param uris
* @return a map of URI -> ontology entry for the incoming URIs.
*/
private Map<String, OntologyEntryBean> lookupOntologyEntriesByUri(Collection<String> uris) {
Map<String, OntologyEntryBean> annotationMap = new HashMap<>();
String query = "*:*";
String filters = buildFilterString(uris);
try {
ResultsList<OntologyEntryBean> results = ontologySearch.searchOntology(query, Arrays.asList(filters), 0, uris.size());
for (OntologyEntryBean anno : results.getResults()) {
annotationMap.put(anno.getUri(), anno);
}
} catch (SearchEngineException e) {
LOGGER.error("Problem getting ontology entries for filter {}: {}", filters, e.getMessage());
}
return annotationMap;
}
/**
* Build a filter string for a set of URIs.
* @param uris
* @return a filter string.
*/
private String buildFilterString(Collection<String> uris) {
StringBuilder sb = new StringBuilder(SolrOntologySearch.URI_FIELD).append(":(");
int idx = 0;
for (String uri : uris) {
if (idx > 0) {
sb.append(" OR ");
}
sb.append("\"").append(uri).append("\"");
idx ++;
}
sb.append(")");
return sb.toString();
}
/**
* Get all of the parent URIs for a collection of ontology entries.
* @param annotations
* @return the parent URIs.
*/
private Set<String> extractParentUris(Collection<OntologyEntryBean> annotations) {
Set<String> parentUris = new HashSet<>();
for (OntologyEntryBean anno : annotations) {
parentUris.addAll(anno.getAncestorUris());
}
return parentUris;
}
private Map<Integer, List<OntologyEntryBean>> collateAnnotationsByLevel(Map<String, OntologyEntryBean> annotationMap) {
Map<Integer, List<OntologyEntryBean>> levelMap = new TreeMap<>();
for (OntologyEntryBean anno : annotationMap.values()) {
int level = anno.getAncestorUris().size();
if (!levelMap.containsKey(level)) {
levelMap.put(level, new ArrayList<OntologyEntryBean>());
}
levelMap.get(level).add(anno);
}
return levelMap;
}
}