/*
* #!
* Ontopia Navigator
* #-
* Copyright (C) 2001 - 2013 The Ontopia Project
* #-
* 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
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 net.ontopia.topicmaps.nav2.portlets.pojos;
import java.util.Map;
import java.util.Set;
import java.util.List;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collection;
import java.util.Collections;
import net.ontopia.utils.StringifierIF;
import net.ontopia.utils.CompactHashSet;
import net.ontopia.utils.ObjectUtils;
import net.ontopia.utils.OntopiaRuntimeException;
import net.ontopia.topicmaps.core.TopicIF;
import net.ontopia.topicmaps.core.TMObjectIF;
import net.ontopia.topicmaps.core.TopicMapIF;
import net.ontopia.topicmaps.core.AssociationIF;
import net.ontopia.topicmaps.core.AssociationRoleIF;
import net.ontopia.topicmaps.utils.TopicStringifiers;
import net.ontopia.topicmaps.utils.KeyGenerator;
import net.ontopia.topicmaps.query.utils.QueryUtils;
import net.ontopia.topicmaps.query.core.ParsedQueryIF;
import net.ontopia.topicmaps.query.core.QueryResultIF;
import net.ontopia.topicmaps.query.core.QueryProcessorIF;
import net.ontopia.topicmaps.query.core.DeclarationContextIF;
import net.ontopia.topicmaps.query.core.InvalidQueryException;
import net.ontopia.topicmaps.query.impl.basic.QueryProcessor;
/**
* PUBLIC: This component can produce a model representing the
* associations of a given topic.
*/
public class RelatedTopics {
/**
* PUBLIC: Flag used to indicated ascending ordering.
*/
public static final int ORDERING_ASC = 1;
/**
* PUBLIC: Flag used to indicated descending ordering.
*/
public static final int ORDERING_DESC = 2;
// all of these contain object IDs rather than actual objects; this
// avoids problems with hard references
private Set weaktypes;
private Set exclassocs;
private Set exclroles;
private Set excltopics;
private Set inclassocs;
private Set incltopics;
// if we have to load the configuration from the topic map it gets cached
// here
private Set weaktypes_cache;
private Set exclassocs_cache;
private Set exclroles_cache;
private Set excltopics_cache;
private StringifierIF sort; // this one is also cached
// the maximum number of children to show
private int maxchildren = -1;
// queries
private String headingOrderQueryString;
private int headingOrdering = ORDERING_ASC;
private String childOrderQueryString;
private int childOrdering = ORDERING_ASC;
private String filterquery;
private DeclarationContextIF tologctx;
// aggregation
private boolean aggregateHierarchy;
private Set aggregateAssociations;
// the system identity hash of the store from which we populated the
// _cache variables last time
private int storeid;
// the system identity hash of the topic map from which we populated the
// _cache variables last time
private int tmid;
private boolean useOntopolyNames;
public RelatedTopics() {
}
// configuration
/**
* PUBLIC: Set the set of association types which is to be
* considered <em>weak</em> in the sense that associations of these
* types are to be listed under the heading for the topic type of
* the associated topics, and not under the association type.
*/
public void setWeakAssociationTypes(Set weaktypes) {
this.weaktypes = mapToObjectIds(weaktypes);
}
/**
* PUBLIC: Set the set of association types which is not to be
* shown.
*/
public void setExcludeAssociationTypes(Set types) {
this.exclassocs = mapToObjectIds(types);
}
/**
* PUBLIC: Set the set of near roles types which is not to be included.
*
* @since 3.4.2
*/
public void setExcludeRoleTypes(Set types) {
this.exclroles = mapToObjectIds(types);
}
/**
* PUBLIC: Set the set of topic types which is not to be shown. For
* n-ary associations the filter takes effect if any of the roles
* are played by excluded types.
*/
public void setExcludeTopicTypes(Set types) {
this.excltopics = mapToObjectIds(types);
}
/**
* PUBLIC: Set the set of association types which is to be
* shown.
*/
public void setIncludeAssociationTypes(Set types) {
this.inclassocs = mapToObjectIds(types);
}
/**
* PUBLIC: Set the set of topic types which is to be shown. For
* n-ary associations the filter takes effect if any of the roles
* are not played by included types.
*/
public void setIncludeTopicTypes(Set types) {
this.incltopics = mapToObjectIds(types);
}
/**
* PUBLIC: Sets a query to be used to filter topics shown as
* related. Only topics for which the query does <em>not</em>
* return any rows will be shown.
*/
public void setFilterQuery(String query) {
this.filterquery = query;
}
/**
* PUBLIC: Sets the maximum number of children for a heading to show
* by default. A negative value will show all children.
*
* @since 3.4
*/
public void setMaxChildren(int maxchildren) {
this.maxchildren = maxchildren;
}
/**
* PUBLIC: Sets the query to use to get the sort key of each
* heading topic.
*
* @since 3.4
*/
public void setHeadingOrderQuery(String headingOrderQueryString) {
this.headingOrderQueryString = headingOrderQueryString;
}
/**
* PUBLIC: Sets the ordering direction to be used for headings.
*
* @since 3.4.1
*/
public void setHeadingOrdering(int headingOrdering) {
this.headingOrdering = headingOrdering;
}
/**
* PUBLIC: Sets the query to use to get the sort key of each
* child topic.
*
* @since 3.4
*/
public void setChildOrderQuery(String childOrderQueryString) {
this.childOrderQueryString = childOrderQueryString;
}
/**
* PUBLIC: Sets the ordering direction to be used for children.
*
* @since 3.4.1
*/
public void setChildOrdering(int childOrdering) {
this.childOrdering = childOrdering;
}
/**
* PUBLIC: Sets the flag indicating whether to do hierarchy
* aggregation or not.
*
* @since 3.4.2
*/
public void setAggregateHierarchy(boolean aggregateHierarchy) {
this.aggregateHierarchy = aggregateHierarchy;
}
/**
* PUBLIC: Sets the association types to do hierarchy aggregation
* for.
*
* @since 3.4.2
*/
public void setAggregateAssociations(Set aggregateAssociations) {
this.aggregateAssociations = aggregateAssociations;
}
/**
* PUBLIC: Passes in a tolog declaration context to be used when
* parsing tolog queries.
*
* @since 3.4.2
*/
public void setTologContext(DeclarationContextIF tologctx) {
this.tologctx = tologctx;
}
// model building
/**
* PUBLIC: Builds a model representing the associations of the given
* topic.
* @return a list of Heading objects
*/
public List makeModel(TopicIF topic) {
// first, validate the configuration
if (excltopics != null && !excltopics.isEmpty() &&
incltopics != null && !incltopics.isEmpty())
throw new OntopiaRuntimeException("Configuration fields includeTopicTypes and excludeTopicTypes cannot both be specified.");
if (exclassocs != null && !exclassocs.isEmpty() &&
inclassocs != null && !inclassocs.isEmpty())
throw new OntopiaRuntimeException("Configuration fields includeAssociationTypes and excludeAssociationTypes cannot both be specified.");
// then, update the configuration cache
updateCache(topic.getTopicMap());
ParsedQueryIF pquery = null;
if (filterquery != null)
pquery = parse(topic.getTopicMap(), filterquery);
ParsedQueryIF headingOrderQuery = null;
if (headingOrderQueryString != null)
headingOrderQuery = parse(topic.getTopicMap(), headingOrderQueryString);
ParsedQueryIF childOrderQuery = null;
if (childOrderQueryString != null)
childOrderQuery = parse(topic.getTopicMap(), childOrderQueryString);
// group associations by the headings they will wind up under
Map typemap = new HashMap();
Iterator it = getRoles(topic).iterator();
while (it.hasNext()) {
AssociationRoleIF role = (AssociationRoleIF) it.next();
if (isRoleHidden(role, pquery))
continue; // if the filter hides this association we just skip it
AssociationIF assoc = role.getAssociation();
if (getWeakTypes().contains(getObjectId(assoc.getType()))) {
// this is a weak type
if (assoc.getRoles().size() != 2)
throw new OntopiaRuntimeException("Weak associations cannot be " +
"n-ary or unary");
AssociationRoleIF other = getOtherRole(assoc, role);
TopicIF player = other.getPlayer();
TopicIF ttype = null;
if (player.getTypes().size() > 0)
ttype = (TopicIF) player.getTypes().iterator().next();
String key = getObjectId(ttype);
Heading heading = (Heading) typemap.get(key);
if (heading == null) {
heading = new Heading(ttype);
if (headingOrderQuery != null)
heading.setSortKey(getSortKey(ttype, headingOrderQuery));
typemap.put(key, heading);
}
Association child = new Association(role, false);
if (childOrderQuery != null)
child.setSortKey(getSortKey(child.getPlayer(), childOrderQuery));
heading.addChild(child);
} else {
// not a weak type
String key = getObjectId(assoc.getType()) + "." +
getObjectId(role.getType());
Heading heading = (Heading) typemap.get(key);
if (heading == null) {
heading = new Heading(assoc.getType(), role.getType());
if (headingOrderQuery != null)
heading.setSortKey(getSortKey(assoc.getType(), headingOrderQuery));
typemap.put(key, heading);
}
Association child = new Association(role, true);
if (childOrderQuery != null)
child.setSortKey(getSortKey(child.getPlayer(), childOrderQuery));
heading.addChild(child);
}
}
// sort the headings
List headings = new ArrayList(typemap.values());
Collections.sort(headings);
// sort the children
for (int i=0; i < headings.size(); i++) {
Heading heading = (Heading)headings.get(i);
Collections.sort(heading.children);
}
// we're done
return headings;
}
// --- Internal methods
private Collection getRoles(TopicIF topic) {
// if no hierarchy aggregation just return topic's direct roles
if (!aggregateHierarchy) return topic.getRoles();
// build aggregate query
StringBuilder query = new StringBuilder();
query.append("/* #OPTION: optimizer.reorder=false */ ");
query.append("/* #OPTION: optimizer.hierarchy-walker=false */ ");
query.append("using h for i\"http://www.techquila.com/psi/hierarchy/#\" ");
query.append("subordinate($SUP, $SUB) :- ");
query.append(" role-player($R1, $SUP), type($R1, $RT1), instance-of($RT1, h:superordinate-role-type), ");
query.append(" association-role($A, $R1), ");
query.append(" association-role($A, $R2), $R1 /= $R2, ");
query.append(" type($A, $AT), instance-of($AT, h:hierarchical-relation-type), ");
query.append(" type($R2, $RT2), instance-of($RT2, h:subordinate-role-type), ");
query.append(" role-player($R2, $SUB). ");
query.append("hierarchy($SUP, $SUB) :- ");
query.append(" { subordinate($SUP, $SUB) | ");
query.append(" subordinate($SUP, $X), hierarchy($X, $SUB) }. ");
query.append("select $R from ");
query.append("{ $T = %topic% | hierarchy(%topic%, $T) }, ");
query.append("role-player($R, $T), association-role($A, $R), type($A, $AT), ");
if (aggregateAssociations != null && !aggregateAssociations.isEmpty()) {
// aggregate only given association types
boolean useOrBranch = (aggregateAssociations.size() > 1);
if (useOrBranch) query.append("{ ");
Iterator iter = aggregateAssociations.iterator();
while (iter.hasNext()) {
TopicIF atype = (TopicIF)iter.next();
query.append("$AT = @");
query.append(atype.getObjectId());
if (iter.hasNext()) query.append(" | ");
}
if (useOrBranch) query.append(" }");
} else {
// aggregate all except hierarchical association types
query.append("not(instance-of($AT, h:hierarchical-relation-type))");
}
query.append(" order by $R?");
// execute query
Map result = new HashMap();
QueryProcessorIF proc = QueryUtils.getQueryProcessor(topic.getTopicMap());
try {
QueryResultIF qr = proc.execute(query.toString(), Collections.singletonMap("topic", topic));
try {
while (qr.next()) {
AssociationRoleIF role = (AssociationRoleIF)qr.getValue(0);
String rkey = KeyGenerator.makeAssociationKey(role.getAssociation(), role);
if (!result.containsKey(rkey))
result.put(rkey, role);
}
} finally {
qr.close();
}
} catch (InvalidQueryException e) {
throw new OntopiaRuntimeException(e);
}
return result.values();
}
private boolean isRoleHidden(AssociationRoleIF role, ParsedQueryIF pquery) {
// is the association type filtered out?
AssociationIF assoc = role.getAssociation();
Set hide = (exclassocs == null) ? exclassocs_cache : exclassocs;
if (hide.contains(getObjectId(assoc.getType())) || // filtered by exclude
(inclassocs != null && !inclassocs.contains(getObjectId(assoc.getType())))) // filtered by include
return true;
// is the role type filtered out?
if (exclroles != null && exclroles.contains(getObjectId(role.getType())))
return true;
// are any of the topics in the association filtered out?
hide = (excltopics == null) ? excltopics_cache : excltopics;
if (hide.isEmpty() && pquery == null)
return false;
Iterator it = assoc.getRoles().iterator();
while (it.hasNext()) {
AssociationRoleIF other = (AssociationRoleIF) it.next();
if (other == role)
continue;
if (isTopicHidden(other.getPlayer(), hide, pquery))
return true;
}
return false;
}
private boolean isTopicHidden(TopicIF topic, Set hide, ParsedQueryIF pquery) {
boolean inclTopicMatch = false;
Iterator it = topic.getTypes().iterator();
while (it.hasNext()) {
TopicIF type = (TopicIF) it.next();
// FIXME: should we support subtyping here?
if (hide.contains(getObjectId(type)))
return true;
if (!inclTopicMatch)
inclTopicMatch = (incltopics != null && incltopics.contains(getObjectId(type)));
}
return (incltopics != null && !inclTopicMatch) ||
(pquery != null && istrue(pquery, topic));
}
private Object getSortKey(TopicIF topic, ParsedQueryIF skquery) {
if (topic == null) return null;
QueryResultIF result = null;
try {
result = skquery.execute(Collections.singletonMap("topic", topic));
if (result.next())
return result.getValue(0);
else
return null;
} catch (InvalidQueryException e) {
throw new OntopiaRuntimeException(e);
} finally {
if (result != null)
result.close();
}
}
private Set getWeakTypes() {
return weaktypes == null ? weaktypes_cache : weaktypes;
}
private String getObjectId(TMObjectIF object) {
if (object == null)
return "NULL";
else
return object.getObjectId();
}
private static AssociationRoleIF getOtherRole(AssociationIF assoc,
AssociationRoleIF role) {
// INV: assoc.getRoles().size() == 2
Iterator it = assoc.getRoles().iterator();
AssociationRoleIF other = (AssociationRoleIF) it.next();
if (other == role)
other = (AssociationRoleIF) it.next();
return other;
}
private Set mapToObjectIds(Set objects) {
if (objects == null) return null;
Set objids = new CompactHashSet(objects.size());
Iterator it = objects.iterator();
while (it.hasNext()) {
TMObjectIF obj = (TMObjectIF) it.next();
objids.add(obj.getObjectId());
}
return objids;
}
private void updateCache(TopicMapIF topicmap) {
if (System.identityHashCode(topicmap.getStore()) == storeid &&
System.identityHashCode(topicmap) == tmid)
return; // we already have this
String decl = "using port for i\"http://psi.ontopia.net/portlets/\" ";
QueryProcessorIF proc = QueryUtils.getQueryProcessor(topicmap);
weaktypes_cache = new CompactHashSet();
try {
QueryResultIF result = proc.execute(decl +
"port:not-semantic-type($AT : port:type)?");
while (result.next())
weaktypes_cache.add(((TopicIF) result.getValue(0)).getObjectId());
result.close();
} catch (InvalidQueryException e) {
// happens if the port:* topics don't exist in the TM; that's OK
}
excltopics_cache = new CompactHashSet();
try {
QueryResultIF result = proc.execute(decl +
"port:is-hidden-topic-type($AT : port:type)?");
while (result.next())
excltopics_cache.add(((TopicIF) result.getValue(0)).getObjectId());
result.close();
} catch (InvalidQueryException e) {
// happens if the port:* topics don't exist in the TM; that's OK
}
exclassocs_cache = new CompactHashSet();
try {
QueryResultIF result = proc.execute(decl +
"port:is-hidden-association-type($AT : port:type)?");
while (result.next())
exclassocs_cache.add(((TopicIF) result.getValue(0)).getObjectId());
result.close();
} catch (InvalidQueryException e) {
// happens if the port:* topics don't exist in the TM; that's OK
}
sort = TopicStringifiers.getFastSortNameStringifier(topicmap);
storeid = System.identityHashCode(topicmap.getStore());
tmid = System.identityHashCode(topicmap);
}
private ParsedQueryIF parse(TopicMapIF tm, String query) {
try {
QueryProcessorIF proc = QueryUtils.getQueryProcessor(tm);
return proc.parse(query, tologctx);
} catch (InvalidQueryException e) {
throw new OntopiaRuntimeException(e);
}
}
private boolean istrue(ParsedQueryIF pquery, TopicIF topic) {
QueryResultIF result = null;
try {
result = pquery.execute(Collections.singletonMap("topic", topic));
return result.next();
} catch (InvalidQueryException e) {
throw new OntopiaRuntimeException(e);
} finally {
if (result != null)
result.close();
}
}
private int compareHeadings(Object o1, Object o2) {
int result = _compare(o1, o2);
if (headingOrdering == ORDERING_DESC)
return -1 * result;
else
return result;
}
private int compareChildren(Object o1, Object o2) {
int result = _compare(o1, o2);
if (childOrdering == ORDERING_DESC)
return -1 * result;
else
return result;
}
private int _compare(Object o1, Object o2) {
// NOTE: helper method that compares object in much the same way as
if (o1 instanceof String && o2 instanceof String) {
// sort string case insensitively
return ObjectUtils.compareIgnoreCase((String)o1, (String)o2);
} else if (o1 instanceof Comparable && o2 instanceof Comparable) {
// compare comparable objects
return ((Comparable)o1).compareTo((Comparable)o2);
} else if (o1 instanceof TopicIF && o2 instanceof TopicIF) {
// compare topics
TopicIF t1 = (TopicIF)o1;
TopicIF t2 = (TopicIF)o2;
String s1 = sort.toString(t1);
String s2 = sort.toString(t2);
return ObjectUtils.compareIgnoreCase(s1, s2);
}
//! else if (o1 instanceof TMObjectIF && o2 instanceof TMObjectIF) {
//! // compare tmobjects
//! TMObjectIF t1 = (TMObjectIF)o1;
//! TMObjectIF t2 = (TMObjectIF)o2;
//! return ObjectUtils.compareIgnoreCase(t1.getObjectId(), t2.getObjectId());
//! }
throw new OntopiaRuntimeException("Unsupported sort keys: " + o1 + " and " + o2);
}
public void setUseOntopolyNames(boolean useOntopolyNames) {
this.useOntopolyNames = useOntopolyNames;
}
// --- Heading
public class Heading implements Comparable {
private TopicIF topic;
private TopicIF nearRoleType;
private List children;
private boolean isTopicType;
private int arity;
private Object sortkey;
private Heading(TopicIF topic, TopicIF nearRoleType) {
this.topic = topic;
this.nearRoleType = nearRoleType;
this.isTopicType = false;
this.children = new ArrayList();
this.arity = 0;
}
private Heading(TopicIF topic) {
this.topic = topic;
this.isTopicType = true;
this.children = new ArrayList();
this.arity = 0;
}
/**
* Adds a new association to the heading given the near association
* role.
* @param assoctype Whether or not the parent heading represents
* an association type.
*/
private void addChild(Association assoc) {
children.add(assoc);
arity = Math.max(arity, assoc.getArity());
}
public String getTitle() {
if (useOntopolyNames && nearRoleType != null) {
// get name from ontopoly role field
QueryProcessorIF proc = QueryUtils.getQueryProcessor(topic.getTopicMap());
try {
StringBuilder query = new StringBuilder();
query.append("using on for i\"http://psi.ontopia.net/ontology/\" ");
query.append("select $NAME from ");
query.append("on:has-association-type(%AT% : on:association-type, $AF : on:association-field), ");
query.append("on:has-association-field($AF : on:association-field, $RF : on:role-field), ");
query.append("on:has-role-type($RF : on:role-field, %RT% : on:role-type), ");
query.append("topic-name($RF, $TN), value($TN, $NAME) ");
query.append("limit 1?");
Map params = new HashMap(2);
params.put("AT", topic);
params.put("RT", nearRoleType);
QueryResultIF qr = proc.execute(query.toString(), params);
try {
if (qr.next()) {
return (String)qr.getValue(0);
}
} finally {
qr.close();
}
} catch (InvalidQueryException e) {
throw new OntopiaRuntimeException(e);
}
}
Collection scope = (nearRoleType != null ? Collections.singleton(nearRoleType) : Collections.EMPTY_SET);
StringifierIF strify = TopicStringifiers.getTopicNameStringifier(scope);
return strify.toString(topic);
}
public void setSortKey(Object sortkey) {
this.sortkey = sortkey;
}
public int compareTo(Object o) {
if (!(o instanceof Heading)) return 0;
Heading other = (Heading)o;
// prefer sort key over title
Object tkey = (this.sortkey != null ? this.sortkey : this.getTitle());
Object okey = (other.sortkey != null ? other.sortkey : other.getTitle());
//! System.out.println("h:" + tkey + " <-> " + okey + " = " + ObjectUtils.compareIgnoreCase(tkey, okey));
try {
return compareHeadings(tkey, okey);
} catch (ClassCastException e) {
throw new OntopiaRuntimeException("Heading sort keys cannot be compared: " + tkey + " and " + okey);
}
}
public TopicIF getTopic() {
return topic;
}
public TopicIF getNearRoleType() {
return nearRoleType;
}
public boolean getIsTopicType() {
return isTopicType;
}
public boolean getIsAssociationType() {
return !isTopicType;
}
public int getArity() {
return arity;
}
public List getChildren() {
// only return maximum number of children if specified
if (maxchildren >= 0 && children.size() > maxchildren)
return children.subList(0, maxchildren);
else
return children;
}
public boolean getMoreChildren() {
return (maxchildren >= 0 && children.size() > maxchildren);
}
}
// --- Association
public class Association implements Comparable {
private AssociationRoleIF role;
private AssociationIF assoc;
private boolean assoctype;
private List roles;
private TopicIF player;
private TopicIF roleType;
private Object sortkey;
/**
* Creates a new association.
* @param assoctype Whether or not the parent heading represents
* an association type.
*/
private Association(AssociationRoleIF role, boolean assoctype) {
this.role = role;
this.assoc = role.getAssociation();
this.assoctype = assoctype;
if (getArity() == 2) {
AssociationRoleIF other = getOtherRole(assoc, role);
this.player = other.getPlayer();
if (assoctype)
this.roleType = other.getType();
}
}
public int getArity() {
if (assoctype)
return assoc.getRoles().size();
else
return 2;
}
public TopicIF getPlayer() {
return player;
}
public List getRoles() {
if (!assoctype || roles != null)
return roles;
roles = new ArrayList(getArity());
Iterator it = assoc.getRoles().iterator();
while (it.hasNext()) {
AssociationRoleIF role = (AssociationRoleIF) it.next();
if (role == this.role)
continue;
roles.add(role);
}
return roles;
}
public Collection getScope() {
return assoc.getScope();
}
public TopicIF getReifier() {
return assoc.getReifier();
}
public TopicIF getRoleType() {
return roleType;
}
public String getTitle() {
Collection scope = (roleType != null ? Collections.singleton(roleType) : Collections.EMPTY_SET);
StringifierIF strify = TopicStringifiers.getTopicNameStringifier(scope);
return strify.toString(player);
}
public void setSortKey(Object sortkey) {
this.sortkey = sortkey;
}
public int compareTo(Object o) {
if (!(o instanceof Association)) return 0;
Association other = (Association)o;
Object tkey = (this.sortkey != null ? this.sortkey : this.getTitle());
Object okey = (other.sortkey != null ? other.sortkey : other.getTitle());
//! System.out.println("a:" + tkey + " <-> " + okey + " = " + ObjectUtils.compareIgnoreCase(tkey, okey));
try {
return compareChildren(tkey, okey);
} catch (ClassCastException e) {
throw new OntopiaRuntimeException("Child sort keys cannot be compared: " + tkey + " and " + okey);
}
}
}
}