PatientSearch.java

/**
 * The contents of this file are subject to the OpenMRS Public License
 * Version 1.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://license.openmrs.org
 *
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
 * License for the specific language governing rights and limitations
 * under the License.
 *
 * Copyright (C) OpenMRS, LLC.  All Rights Reserved.
 */
package org.openmrs.reporting;

import java.io.StreamTokenizer;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import java.util.Stack;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.openmrs.api.PatientSetService;
import org.openmrs.api.PatientSetService.BooleanOperator;
import org.openmrs.cohort.CohortDefinition;
import org.openmrs.cohort.CohortSearchHistory;
import org.openmrs.cohort.CohortUtil;
import org.openmrs.report.EvaluationContext;
import org.openmrs.report.Parameter;
import org.openmrs.util.OpenmrsUtil;
import org.simpleframework.xml.Attribute;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.ElementList;
import org.simpleframework.xml.Root;

/**
 * This class represents a search for a set of patients, as entered from a user interface. There are
 * different types of searches:
 * <ul>
 * <li>a composition, e.g. "1 and (2 or 3)"
 * <li>a reference to a saved filter, expressed as the database integer pk.
 * <li>a reference to a saved cohort, expressed as the database integer pk.
 * <li>a regular search, which describes a PatientFilter subclass and a list of bean-style
 * properties to set.
 * </ul>
 * Composition filters:<br/>
 * When isComposition() returns true, then this represents something like "1 and (2 or 3)", which
 * must be evaluated in the context of a search history.
 * <p>
 * Saved filters:<br/>
 * When isSavedFilterReference() returns true, then this represents something like "saved filter #8"
 * <br/>
 * When isSavedCohortReference() returns true, then this represents something like "saved cohort #3"
 * <p>
 * Regular filters:<br/>
 * Otherwise this search describes a PatientFilter subclass and a list of bean-style properties to
 * set, so that it can be turned into a PatientFilter with the utility method
 * OpenmrsUtil.toPatientFilter(PatientSearch). But it can also be left as-is for better
 * version-compatibility if PatientFilter classes change, or to avoid issues with xml-encoding
 * hibernate proxies.
 * 
 * @deprecated see reportingcompatibility module
 */
@Root(strict = false)
@Deprecated
public class PatientSearch implements CohortDefinition {
	
	private static final long serialVersionUID = -8913742497675209159L;
	
	protected static final Log log = LogFactory.getLog(PatientSearch.class);
	
	private static Set<String> andWords = new HashSet<String>();
	
	private static Set<String> orWords = new HashSet<String>();
	
	private static Set<String> notWords = new HashSet<String>();
	
	private static Set<String> openParenthesesWords = new HashSet<String>();
	
	private static Set<String> closeParenthesesWords = new HashSet<String>();
	static {
		andWords.add("and");
		andWords.add("intersection");
		andWords.add("*");
		orWords.add("or");
		orWords.add("union");
		orWords.add("+");
		notWords.add("not");
		notWords.add("!");
		openParenthesesWords.add("(");
		openParenthesesWords.add("[");
		openParenthesesWords.add("{");
		closeParenthesesWords.add(")");
		closeParenthesesWords.add("]");
		closeParenthesesWords.add("}");
	}
	
	private Class<PatientFilter> filterClass;
	
	private List<SearchArgument> arguments;
	
	private List<Object> parsedComposition;
	
	private Integer savedSearchId;
	
	private Integer savedFilterId;
	
	private Integer savedCohortId;
	
	// Temporary storage for user-specified parameter values. This is a bit of a hack.  
	private transient Map<String, String> parameterValues = new HashMap<String, String>();
	
	// static factory methods:
	public static PatientSearch createSavedSearchReference(int id) {
		PatientSearch ps = new PatientSearch();
		ps.setSavedSearchId(id);
		return ps;
	}
	
	public static PatientSearch createSavedFilterReference(int id) {
		PatientSearch ps = new PatientSearch();
		ps.setSavedFilterId(id);
		return ps;
	}
	
	public static PatientSearch createSavedCohortReference(int id) {
		PatientSearch ps = new PatientSearch();
		ps.setSavedCohortId(id);
		return ps;
	}
	
	public static PatientSearch createCompositionSearch(String description) {
		// TODO This is a rewrite of the code in CohortSearchHistory.createCompositionFilter(String). That method should probably delegate to this one in some way.
		// TODO use open/closeParenthesesWords declared above
		List<Object> tokens = new ArrayList<Object>();
		try {
			StreamTokenizer st = new StreamTokenizer(new StringReader(description));
			st.ordinaryChar('(');
			st.ordinaryChar(')');
			while (st.nextToken() != StreamTokenizer.TT_EOF) {
				if (st.ttype == StreamTokenizer.TT_NUMBER) {
					Integer thisInt = new Integer((int) st.nval);
					if (thisInt < 1) {
						log.error("number < 1");
						return null;
					}
					tokens.add(thisInt);
				} else if (st.ttype == '(') {
					tokens.add("(");
				} else if (st.ttype == ')') {
					tokens.add(")");
				} else if (st.ttype == StreamTokenizer.TT_WORD) {
					String str = st.sval.toLowerCase();
					tokens.add(str);
				}
			}
			return createCompositionSearch(tokens);
		}
		catch (Exception ex) {
			log.error("Error in description string: " + description, ex);
			return null;
		}
	}
	
	public static PatientSearch createCompositionSearch(Object[] tokens) {
		return createCompositionSearch(Arrays.asList(tokens));
	}
	
	public static PatientSearch createCompositionSearch(List<Object> tokens) {
		// TODO This is a rewrite of the code in CohortSearchHistory.createCompositionFilter(String). That method should probably delegate to this one in some way.
		List<Object> currentLine = new ArrayList<Object>();
		
		try {
			Stack<List<Object>> stack = new Stack<List<Object>>();
			for (Object token : tokens) {
				if (token instanceof String) {
					String s = (String) token;
					s = s.toLowerCase();
					if (andWords.contains(s)) {
						currentLine.add(PatientSetService.BooleanOperator.AND);
					} else if (orWords.contains(s)) {
						currentLine.add(PatientSetService.BooleanOperator.OR);
					} else if (notWords.contains(s)) {
						currentLine.add(PatientSetService.BooleanOperator.NOT);
					} else if (openParenthesesWords.contains(s)) {
						stack.push(currentLine);
						currentLine = new ArrayList<Object>();
					} else if (closeParenthesesWords.contains(s)) {
						List<Object> l = stack.pop();
						l.add(currentLine);
						currentLine = l;
					} else {
						throw new IllegalArgumentException("Unrecognized string token: " + s);
					}
				} else if (token instanceof Integer) {
					currentLine.add(token);
				} else if (token instanceof PatientSearch) {
					currentLine.add(token);
				} else if (token instanceof PatientFilter) {
					currentLine.add(token);
				} else {
					throw new IllegalArgumentException("Unknown class in token list: " + token.getClass());
				}
			}
		}
		catch (Exception ex) {
			log.error("Error in token list", ex);
			return null;
		}
		
		PatientSearch ret = new PatientSearch();
		ret.setParsedComposition(currentLine);
		return ret;
	}
	
	@SuppressWarnings("unchecked")
	public static PatientSearch createFilterSearch(Class filterClass) {
		PatientSearch ps = new PatientSearch();
		ps.setFilterClass(filterClass);
		ps.setArguments(new ArrayList<SearchArgument>());
		return ps;
	}
	
	// constructors and instance methods
	
	public PatientSearch() {
	}
	
	public String toString() {
		StringBuilder sb = new StringBuilder();
		sb.append("PatientSearch");
		if (getSavedCohortId() != null)
			sb.append(" savedCohortId=" + getSavedCohortId());
		if (getSavedFilterId() != null)
			sb.append(" savedFilterId=" + getSavedFilterId());
		if (getSavedSearchId() != null)
			sb.append(" savedSearchId=" + getSavedSearchId());
		if (getFilterClass() != null) {
			sb.append(" filterClass=" + getFilterClass());
			if (getArguments() != null)
				for (SearchArgument sa : getArguments())
					sb.append(" (" + sa.getPropertyClass() + ")" + sa.getName() + "=" + sa.getValue());
		}
		if (getParsedComposition() != null) {
			sb.append(" parsedComposition=");
			for (Object o : getParsedComposition())
				sb.append("\n" + o);
		}
		if (parameterValues != null)
			for (Map.Entry<String, String> e : parameterValues.entrySet())
				sb.append(" paramValue:" + e.getKey() + "=" + e.getValue());
		return sb.toString();
	}
	
	public boolean isComposition() {
		return parsedComposition != null;
	}
	
	public String getCompositionString() {
		if (parsedComposition == null)
			return null;
		else
			return compositionStringHelper(parsedComposition);
	}
	
	/**
	 * Convenience method so that a PatientSearch object can be created from a string of
	 * compositions
	 * 
	 * @param specification
	 */
	@Element(data = true, name = "specification", required = false)
	public void setSpecificationString(String specification) {
		PatientSearch temp = (PatientSearch) CohortUtil.parse(specification);
		if (temp == null)
			throw new IllegalArgumentException("Couldn't parse: " + specification);
		this.setParsedComposition(temp.getParsedComposition());
		this.setSavedSearchId(temp.getSavedSearchId());
		this.setSavedFilterId(temp.getSavedFilterId());
		this.setSavedCohortId(temp.getSavedCohortId());
		this.setFilterClass(temp.getFilterClass());
		if (temp.getArguments() != null)
			this.setArguments(new ArrayList<SearchArgument>(temp.getArguments()));
		else
			this.setArguments(null);
	}
	
	@Element(data = true, name = "specification", required = false)
	public String getSpecificationString() {
		return "Not Yet Implemented";
	}
	
	@SuppressWarnings("unchecked")
	private String compositionStringHelper(List list) {
		StringBuilder ret = new StringBuilder();
		for (Object o : list) {
			if (ret.length() > 0)
				ret.append(" ");
			if (o instanceof List)
				ret.append("(" + compositionStringHelper((List) o) + ")");
			else
				ret.append(o);
		}
		return ret.toString();
	}
	
	/**
	 * @return Whether this search requires a history against which to evaluate it
	 */
	public boolean requiresHistory() {
		if (isComposition()) {
			return requiresHistoryHelper(parsedComposition);
		} else
			return false;
	}
	
	private boolean requiresHistoryHelper(List<Object> list) {
		for (Object o : list) {
			if (o instanceof Integer)
				return true;
			else if (o instanceof PatientSearch)
				return ((PatientSearch) o).requiresHistory();
			else if (o instanceof List) {
				if (requiresHistoryHelper((List<Object>) o))
					return true;
			}
		}
		return false;
	}
	
	/**
	 * Creates a copy of this PatientSearch that doesn't depend on history, replacing references
	 * with actual PatientSearch elements from the provided history. The PatientSearch object
	 * returned is only a copy when necessary to detach it from history. This method does NOT do a
	 * clone.
	 */
	public PatientSearch copyAndDetachFromHistory(CohortSearchHistory history) {
		if (isComposition() && requiresHistory()) {
			PatientSearch copy = new PatientSearch();
			copy.setParsedComposition(copyAndDetachHelper(parsedComposition, history));
			return copy;
		} else
			return this;
	}
	
	@SuppressWarnings("unchecked")
	private List<Object> copyAndDetachHelper(List<Object> list, CohortSearchHistory history) {
		List<Object> ret = new ArrayList<Object>();
		for (Object o : list) {
			if (o instanceof PatientSearch) {
				ret.add(((PatientSearch) o).copyAndDetachFromHistory(history));
			} else if (o instanceof Integer) {
				PatientSearch ps = history.getSearchHistory().get(((Integer) o) - 1);
				ret.add(ps.copyAndDetachFromHistory(history));
			} else if (o instanceof List) {
				ret.add(copyAndDetachHelper((List) o, history));
			} else
				ret.add(o);
		}
		return ret;
	}
	
	/**
	 * Deep-copies this.parsedComposition, and converts to filters, in the context of history
	 */
	public CohortHistoryCompositionFilter cloneCompositionAsFilter(CohortSearchHistory history) {
		return cloneCompositionAsFilter(history, null);
	}
	
	/**
	 * Deep-copies this.parsedComposition, and converts to filters, in the context of history
	 */
	public CohortHistoryCompositionFilter cloneCompositionAsFilter(CohortSearchHistory history, EvaluationContext evalContext) {
		List<Object> list = cloneCompositionHelper(parsedComposition, history, evalContext);
		CohortHistoryCompositionFilter pf = new CohortHistoryCompositionFilter();
		pf.setParsedCompositionString(list);
		pf.setHistory(history);
		return pf;
	}
	
	@SuppressWarnings("unchecked")
	private List<Object> cloneCompositionHelper(List<Object> list, CohortSearchHistory history, EvaluationContext evalContext) {
		List<Object> ret = new ArrayList<Object>();
		for (Object o : list) {
			if (o instanceof List)
				ret.add(cloneCompositionHelper((List) o, history, evalContext));
			else if (o instanceof Integer)
				ret.add(history.ensureCachedFilter((Integer) o - 1));
			else if (o instanceof BooleanOperator)
				ret.add(o);
			else if (o instanceof PatientFilter)
				ret.add(o);
			else if (o instanceof PatientSearch)
				ret.add(OpenmrsUtil.toPatientFilter((PatientSearch) o, history, evalContext));
			else
				throw new RuntimeException("Programming Error: forgot to handle: " + o.getClass());
		}
		return ret;
	}
	
	public boolean isSavedReference() {
		return isSavedSearchReference() || isSavedFilterReference() || isSavedCohortReference();
	}
	
	public boolean isSavedSearchReference() {
		return savedSearchId != null;
	}
	
	public boolean isSavedFilterReference() {
		return savedFilterId != null;
	}
	
	public boolean isSavedCohortReference() {
		return savedCohortId != null;
	}
	
	/**
	 * Call this to notify this composition search that the _i_th element of the search history has
	 * been removed, and the search potentially needs to renumber its constituent parts. Examples,
	 * assuming this search is "1 and (4 or
	 * 5)": * removeFromHistoryNotify(1) -> This search becomes "1 and (3 or 4)" and the method
	 * return false * removeFromHistoryNotify(3) -> This search becomes invalid, and the method
	 * returns true * removeFromHistoryNotify(9) -> This search is unaffected, and the method
	 * returns false
	 * 
	 * @return whether or not this search itself should be removed (because it directly references
	 *         the removed history element
	 */
	public boolean removeFromHistoryNotify(int i) {
		if (!isComposition())
			throw new IllegalArgumentException("Can only call this method on a composition search");
		return removeHelper(parsedComposition, i);
	}
	
	@SuppressWarnings("unchecked")
	private boolean removeHelper(List<Object> list, int i) {
		boolean ret = false;
		for (ListIterator<Object> iter = list.listIterator(); iter.hasNext();) {
			Object o = iter.next();
			if (o instanceof List)
				ret |= removeHelper((List<Object>) o, i);
			else if (o instanceof Integer) {
				Integer ref = (Integer) o;
				if (ref == i) {
					ret = true;
					iter.set("-1");
				} else if (ref > i)
					iter.set(ref - 1);
			}
		}
		return ret;
	}
	
	/**
	 * Looks up an argument value, accounting for parameterValues
	 * 
	 * @param name
	 * @return the <code>String</code> value for the specified argument
	 */
	public String getArgumentValue(String name) {
		if (parameterValues.containsKey(name))
			return parameterValues.get(name);
		for (SearchArgument sa : arguments)
			if (sa.getName().equals(name))
				return sa.getValue();
		return null;
	}
	
	@ElementList(required = false)
	public List<SearchArgument> getArguments() {
		return arguments;
	}
	
	@ElementList(required = false)
	public void setArguments(List<SearchArgument> arguments) {
		this.arguments = arguments;
	}
	
	/**
	 * Returns all SearchArgument values that match
	 * {@link org.openmrs.report.EvaluationContext#parameterValues}
	 * 
	 * @return <code>List&lt;Parameter></code> of all parameters in the arguments
	 */
	public List<Parameter> getParameters() {
		List<Parameter> parameters = new ArrayList<Parameter>();
		if (arguments != null) {
			for (SearchArgument a : arguments) {
				String value = parameterValues.get(a.getName());
				if (value == null)
					value = a.getValue();
				if (EvaluationContext.isExpression(value)) {
					parameters.add(new Parameter(a.getName(), a.getName(), a.getPropertyClass(), value));
				}
			}
		}
		return parameters;
	}
	
	@SuppressWarnings("unchecked")
	@Attribute(required = false)
	public Class getFilterClass() {
		return filterClass;
	}
	
	@SuppressWarnings("unchecked")
	@Attribute(required = false)
	public void setFilterClass(Class clazz) {
		if (clazz != null && !PatientFilter.class.isAssignableFrom(clazz))
			throw new IllegalArgumentException(clazz + " is not an org.openmrs.PatientFilter");
		this.filterClass = clazz;
	}
	
	@SuppressWarnings("unchecked")
	public void addArgument(String name, String value, Class clz) {
		addArgument(new SearchArgument(name, value, clz));
	}
	
	public void addArgument(SearchArgument sa) {
		if (arguments == null)
			arguments = new ArrayList<SearchArgument>();
		arguments.add(sa);
	}
	
	/**
	 * Adds a SearchArgument as a Parameter where the SearchArgument name is set to the Parameter
	 * label and SearchArgument value is set to the Parameter name and SearchArgument propertyClass
	 * is set to the Parameter clazz
	 * 
	 * @param parameter
	 */
	public void addParameter(Parameter parameter) {
		addArgument(parameter.getLabel(), parameter.getName(), parameter.getClazz());
	}
	
	//@ElementList(required=false)
	public List<Object> getParsedComposition() {
		return parsedComposition;
	}
	
	/**
	 * Elements in this list can be: an Integer, indicating a 1-based index into a search history a
	 * BooleanOperator (AND, OR, NOT) a PatientFilter a PatientSearch another List of the same form,
	 * which indicates a parenthetical expression
	 */
	//@ElementList(required=false)
	public void setParsedComposition(List<Object> parsedComposition) {
		this.parsedComposition = parsedComposition;
	}
	
	@Attribute(required = false)
	public Integer getSavedSearchId() {
		return savedSearchId;
	}
	
	@Attribute(required = false)
	public void setSavedSearchId(Integer savedSearchId) {
		this.savedSearchId = savedSearchId;
	}
	
	@Attribute(required = false)
	public Integer getSavedFilterId() {
		return savedFilterId;
	}
	
	@Attribute(required = false)
	public void setSavedFilterId(Integer savedFilterId) {
		this.savedFilterId = savedFilterId;
	}
	
	@Attribute(required = false)
	public Integer getSavedCohortId() {
		return savedCohortId;
	}
	
	@Attribute(required = false)
	public void setSavedCohortId(Integer savedCohortId) {
		this.savedCohortId = savedCohortId;
	}
	
	public void setParameterValue(String name, String value) {
		parameterValues.put(name, value);
	}
	
}