Please wait. This can take some minutes ...
Many resources are needed to download a project. Please understand that we have to compensate our server costs. Thank you in advance.
Project price only 1 $
You can buy this project and download/modify it how often you want.
io.uhndata.cards.statistics.StatisticQueryServlet Maven / Gradle / Ivy
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 io.uhndata.cards.statistics;
import java.io.IOException;
import java.io.Writer;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Deque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.Property;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.Value;
import javax.jcr.query.Query;
import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.json.JsonObjectBuilder;
import javax.json.JsonValue;
import javax.json.JsonValue.ValueType;
import javax.json.stream.JsonParser;
import javax.json.stream.JsonParser.Event;
import javax.servlet.Servlet;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
import org.apache.sling.servlets.annotations.SlingServletResourceTypes;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.FieldOption;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.uhndata.cards.serialize.spi.ResourceJsonProcessor;
/**
* A servlet for querying Statistics that returns a JSON object containing values for the x and y axes.
*
* @version $Id: 481e5d1cacd69ac660066915814d469107e85bf7 $
*/
@Component(service = { Servlet.class })
@SlingServletResourceTypes(
resourceTypes = { "cards/Statistic", "cards/StatisticsHomepage" },
selectors = { "query" },
methods = { "POST" })
public class StatisticQueryServlet extends SlingAllMethodsServlet
{
private static final long serialVersionUID = 2558430802619674046L;
private static final Logger LOGGER = LoggerFactory.getLogger(StatisticQueryServlet.class);
private static final String VALUE_PROP = "value";
private static final String LABEL_PROP = "displayedValue";
private static final String VALUE_NOT_SPECIFIED = "Not specified";
// xValueDictionary is used to cache the map from answer displayed values to raw values for the variable
private final ThreadLocal> xValueDictionary = new ThreadLocal<>();
// splitValueDictionary is used to cache the map from answer displayed values to raw values for the split variable
private final ThreadLocal> splitValueDictionary = new ThreadLocal<>();
@Reference(cardinality = ReferenceCardinality.MULTIPLE, fieldOption = FieldOption.REPLACE,
policy = ReferencePolicy.DYNAMIC)
private volatile List allProcessors;
private final ThreadLocal> labelProcessors = new ThreadLocal<>();
@SuppressWarnings({"checkstyle:ExecutableStatementCount"})
@Override
protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response)
throws IOException
{
Map arguments = parseArguments(request);
try {
// Obtain the labels processors and sort them by priority
// They will enable aggregating the stats data by displayedValue
List processors = this.allProcessors.stream()
.filter(p -> "labels".equals(p.getName())).collect(Collectors.toList());
processors.sort((o1, o2) -> o1.getPriority() - o2.getPriority());
this.labelProcessors.set(processors);
// Steps to returning the calculated statistic:
// Grab the question that has data for the given x-axis (xVar)
Session session = request.getResourceResolver().adaptTo(Session.class);
Node question = session.getNode(arguments.get("x-label"));
Iterator answers = null;
Map data = new LinkedHashMap<>();
Map> dataById = null;
// Filter those answers based on whether or not their form's subject is of the correct SubjectType (yVar)
Node correctSubjectType = session.getNode(arguments.get("y-label"));
// Instantiate xLabels
this.xValueDictionary.set(new HashMap<>());
// Grab all answers that have this question filled out, and the split var (if it exists)
boolean isSplit = arguments.containsKey("splitVar");
if (isSplit) {
// Instantiate splitLabels
this.splitValueDictionary.set(new HashMap<>());
Node split = session.getNode(arguments.get("splitVar"));
data = getAnswersWithType(data, "x", question, request.getResourceResolver());
data = getAnswersWithType(data, "split", split, request.getResourceResolver());
// filter if splitVar exists
dataById = filterAnswersWithType(data, correctSubjectType);
} else {
// filter if split does not exist
final StringBuilder query =
// We select all answers that answer our question
new StringBuilder("select n from [" + getAnswerNodeType(question) + "] as n where n.'question'='"
+ question.getIdentifier() + "' order by n.'value' desc option (index tag cards)");
answers = filterAnswersToSubjectType(
request.getResourceResolver().adaptTo(Session.class).getWorkspace().getQueryManager()
.createQuery(query.toString(), Query.JCR_SQL2).execute().getNodes(),
correctSubjectType);
}
String xLabel = question.getProperty("text").getString();
String yLabel = correctSubjectType.getProperty("label").getString();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
String date = simpleDateFormat.format(new Date());
// Add inputs and time generated to the output JSON
JsonObjectBuilder builder = Json.createObjectBuilder();
builder.add("timeGenerated", date);
builder.add("name", arguments.get("name"));
builder.add("x-label", xLabel);
builder.add("y-label", yLabel);
if (isSplit) {
Node split = session.getNode(arguments.get("splitVar"));
String splitLabel = split.getProperty("text").getString();
builder.add("split-label", splitLabel);
addDataSplit(dataById, builder);
this.splitValueDictionary.remove();
} else {
addData(answers, builder);
}
this.xValueDictionary.remove();
// Write the output
response.setContentType("application/json;charset=UTF-8");
final Writer out = response.getWriter();
out.write(builder.build().toString());
} catch (RepositoryException e) {
LOGGER.error("Failed to obtain statistic: {}", e.getMessage(), e);
}
}
/**
* Parse out the arguments given in the POST request into a string map.
*
* @param request the POST request made to this servlet
* @return map of arguments to their values
*/
protected Map parseArguments(SlingHttpServletRequest request) throws IOException
{
JsonParser parser = Json.createParser(request.getInputStream());
Map retVal = new HashMap<>();
while (parser.hasNext()) {
Event event = parser.next();
if (event == JsonParser.Event.KEY_NAME) {
String key = parser.getString();
event = parser.next();
retVal.put(key, parser.getString());
}
}
return retVal;
}
/**
* Split: Get all answers that have a given question filled out.
*
* @param data the map that the return values will be added to
* @param type given type (x or split)
* @param question The question node that the answers is to
* @param resolver Reference to the resource resolver
* @return map containing all question nodes and their given type
*/
private Map getAnswersWithType(final Map data, final String type, final Node question,
final ResourceResolver resolver) throws RepositoryException
{
final StringBuilder query =
// We select all answers that answer our question
new StringBuilder("select n from [" + getAnswerNodeType(question) + "] as n where n.'question'='"
+ question.getIdentifier() + "' order by n.'value' desc option (index tag cards)");
final NodeIterator answers = resolver.adaptTo(Session.class).getWorkspace().getQueryManager()
.createQuery(query.toString(), Query.JCR_SQL2).execute().getNodes();
while (answers.hasNext()) {
final Node answer = answers.nextNode();
data.put(answer, type);
}
return data;
}
/**
* Obtain the answer node type based on the dataType specified in the question definition.
*
* @param question The question node
* @return A string "cards:____Answer" (e.g., cards:TextAnswer, cards:LongAnswer) or "cards:Answer" if obtaining the
* type fails
*/
private String getAnswerNodeType(Node question)
{
String nodeType = "";
try {
final String dataTypeString = question.getProperty("dataType").getString();
nodeType = StringUtils.capitalize(dataTypeString);
} catch (RepositoryException e) {
LOGGER.error("Failed to obtain answer node type: {}", e.getMessage(), e);
}
return "cards:" + nodeType + "Answer";
}
/**
* Split: Filter the given iterator of resources to only include resources whose parent is a Form, whose Subject's
* type is equal to the given subjectType.
*
* @param data Iterator of resources
* @param subjectType Subject type of the subject for the answer's form
* @return The filtered iterator
*/
private Map> filterAnswersWithType(final Map data, final Node subjectType)
throws RepositoryException
{
final Map> newData = new LinkedHashMap<>();
String correctType = subjectType.getIdentifier();
// filter out answers without correct subject type
for (final Entry answer : data.entrySet()) {
// get form node
final Node formNode = getFormNode(answer.getKey());
// get parent subject
Node formSubject = formNode.getProperty("subject").getNode();
boolean foundAnswer = false;
while (formSubject.getDepth() > 0) {
if (formSubject.hasProperty("type")
&& formSubject.getProperty("type").getNode().getIdentifier().equals(correctType)) {
// if it is the correct type, add to new map
String uuid = formSubject.getIdentifier();
// this should create a nested hashmap >
if (newData.containsKey(uuid)) {
// if it does include uuid already
newData.get(uuid).put(answer.getKey(), answer.getValue());
} else {
// if does not already include uuid
final Map subjectData = new LinkedHashMap<>();
subjectData.put(answer.getKey(), answer.getValue());
newData.put(uuid, subjectData);
}
foundAnswer = true;
break;
}
formSubject = formSubject.getParent();
}
if (!foundAnswer) {
LOGGER.warn("Could not find answer for node: " + formNode.getIdentifier());
}
}
return newData;
}
/**
* Split: Aggregate the counts.
*
* @param xVar X variable to use
* @param splitVar Variable to split on
* @param counts Map of {SubjectID, {Split variable label, count}}
* @return map of {x var, {split var, count}}
*/
private Map> aggregateSplitCounts(final Node xVar, final Node splitVar,
final Map> counts) throws RepositoryException
{
Map innerCount = new LinkedHashMap<>();
// We can't count anything without an x variable
if (xVar == null) {
return counts;
}
final List splitValues = getAnswerValues(splitVar, this.splitValueDictionary.get());
final List values = getAnswerValues(xVar, this.xValueDictionary.get());
for (final String xValue : values) {
for (final String splitValue : splitValues) {
// if x value and split value already exist
if (counts.containsKey(xValue) && counts.get(xValue).containsKey(splitValue)) {
counts.get(xValue).put(splitValue, counts.get(xValue).get(splitValue) + 1);
} else if (counts.containsKey(xValue) && !counts.get(xValue).containsKey(splitValue)) {
// if x value already exists, but not split value - create and set to 1
counts.get(xValue).put(splitValue, 1);
} else {
// else, create both and set to 1
innerCount.put(splitValue, 1);
counts.put(xValue, innerCount);
}
}
}
return counts;
}
/**
* Split: add aggregated data to object builder, to be displayed.
*
* @param data Aggregated data
* @param builder The object builder for output
*/
private void addDataSplit(final Map> data, final JsonObjectBuilder builder)
throws RepositoryException
{
Map> counts = new LinkedHashMap<>();
try {
for (final Map.Entry> entries : data.entrySet()) {
Node splitVar = null;
// First, find the split variable
for (final Map.Entry entry : entries.getValue().entrySet()) {
if ("split".equals(entry.getValue())) {
splitVar = entry.getKey();
break;
}
}
// Then, call aggregate split counts once for each x variable
for (final Map.Entry entry : entries.getValue().entrySet()) {
if ("x".equals(entry.getValue())) {
counts = aggregateSplitCounts(entry.getKey(), splitVar, counts);
}
}
}
} catch (Exception e) {
// Do nothing
}
JsonObjectBuilder outerBuilder = Json.createObjectBuilder();
for (Map.Entry> t : counts.entrySet()) {
String key = t.getKey();
JsonObjectBuilder keyBuilder = Json.createObjectBuilder();
for (Map.Entry e : t.getValue().entrySet()) {
// inner object
keyBuilder.add(e.getKey(), e.getValue());
}
// outer object
outerBuilder.add(key, keyBuilder.build());
}
builder.add("data", outerBuilder.build());
// Add value->raw value maps for nice display and filter generation on the frontend
builder.add("xValueDictionary", buildMapAsJson(this.xValueDictionary.get()));
builder.add("splitValueDictionary", buildMapAsJson(this.splitValueDictionary.get()));
}
/**
* No Split: Filter the given iterator of resources to only include resources whose parent is a Form, and whose
* Subject (or an ancestor)'s type is equal to the given subjectType.
*
* @param answers The iterator of answers to filter
* @param subjectType A subjectType to filter for
* @return An iterator of Resources that are Nodes of answers for the given subjectType
*/
private Iterator filterAnswersToSubjectType(final NodeIterator answers, final Node subjectType)
throws RepositoryException
{
final Deque newAnswers = new LinkedList<>();
final String correctType = subjectType.getIdentifier();
while (answers.hasNext()) {
final Node answer = answers.nextNode();
final Node answerParent = getFormNode(answer);
Node answerSubjectType = answerParent.getProperty("subject").getNode().getProperty("type").getNode();
while (answerSubjectType.getDepth() > 0) {
if (answerSubjectType.getIdentifier().equals(correctType)) {
newAnswers.push(answer);
break;
}
// Check the parent instead
answerSubjectType = answerSubjectType.getParent();
}
}
return newAnswers.iterator();
}
/**
* No Split: Aggregate the given Iterator of answers to a map of counts for each unique value.
*
* @param answers An iterator of cards:Answer objects
* @return A map of values -> counts
*/
private Map aggregateCounts(final Iterator answers) throws RepositoryException
{
final Map counts = new LinkedHashMap<>();
while (answers.hasNext()) {
final Node answer = answers.next();
final List values = getAnswerValues(answer, this.xValueDictionary.get());
for (final String value : values) {
// If this already exists in our counts dict, we add 1 to its value
// Otherwise, set it to 1 count
if (counts.containsKey(value)) {
counts.put(value, counts.get(value) + 1);
} else {
counts.put(value, 1);
}
}
}
return counts;
}
/**
* Obtain the answer values as a list, regardless whether it is single or multi valued.
*
* @param answer The cards:Answer node
* @param valueDictionary a Map that contains all value -> raw value pairs encountered in any of the answers to
* a specific variable (either x or split) used for generating the current statistic
* @return A list of strings
*/
private List getAnswerValues(Node answer, Map valueDictionary)
{
List values = new LinkedList<>();
// Call label processors to populate displayedValue
JsonObjectBuilder builder = Json.createObjectBuilder();
this.labelProcessors.get().forEach(p -> p.leave(answer, builder, null));
// Now the json has the displayedValue if a value exists
JsonObject answerJson = builder.build();
JsonValue jsonValue = answerJson.get(LABEL_PROP);
try {
if (!answer.hasProperty(VALUE_PROP)) {
recordEmptyAnswerValue(values, valueDictionary);
return values;
}
Property rawValue = answer.getProperty(VALUE_PROP);
if (jsonValue == null) {
recordEmptyAnswerValue(values, valueDictionary);
} else if (jsonValue.getValueType() == ValueType.ARRAY) {
JsonArray jsonArray = jsonValue.asJsonArray();
if (jsonArray.size() == 0) {
recordEmptyAnswerValue(values, valueDictionary);
} else {
Value[] rawValues = rawValue.getValues();
for (int i = 0; i < jsonArray.size(); ++i) {
recordAnswerValue(values, valueDictionary, jsonArray.getString(i), rawValues[i].getString());
}
}
} else {
recordAnswerValue(values, valueDictionary, answerJson.getString(LABEL_PROP), rawValue.getString());
}
} catch (ClassCastException | RepositoryException e) {
LOGGER.error("Value could not be processed for question: {}", e.getMessage(), e);
if (values.size() == 0) {
recordEmptyAnswerValue(values, valueDictionary);
}
}
return values;
}
/**
* Records the "empty value" into a certain answer's value list and the statistic's overall value dictionary.
*
* @param values a List of strings holding all values for a particular answer
* @param valueDictionary a Map that contains all value -> raw value pairs encountered in any of the answers to
* a specific variable (either x or split) used for generating the current statistic
*/
private void recordEmptyAnswerValue(List values, Map valueDictionary)
{
recordAnswerValue(values, valueDictionary, VALUE_NOT_SPECIFIED, "");
}
/**
* Records an answer value into a certain answer's value list and the statistic's overall value dictionary.
*
* @param values a List of strings holding all values for a particular answer
* @param valueDictionary a Map that contains all value -> raw value pairs encountered in any of the answers to
* a specific variable (either x or split) used for generating the current statistic
* @param value The value to record
* @param rawValue The corresponding raw value, recorder together with value in the dictionary
*/
private void recordAnswerValue(List values, Map valueDictionary, String value,
String rawValue)
{
values.add(value);
valueDictionary.put(value, rawValue);
}
/**
* No Split: Add the counts to the data object.
*
* @param answers Counts object to add
* @param builder Data object to add to
*/
private void addData(final Iterator answers, final JsonObjectBuilder builder) throws RepositoryException
{
// Aggregate our counts
Map counts = aggregateCounts(answers);
// Convert our HashMap into a JsonObject
JsonObjectBuilder dataBuilder = Json.createObjectBuilder();
Iterator keysMap = counts.keySet().iterator();
while (keysMap.hasNext()) {
String key = keysMap.next();
dataBuilder.add(key, counts.get(key));
}
builder.add("data", dataBuilder.build());
// Add value->label maps for nice display on the frontend)
builder.add("xValueDictionary", buildMapAsJson(this.xValueDictionary.get()));
}
/**
* Build a JsonObject from a map.
*
* @param map A String->String map
* @return JsonObject the same data as a json object
*/
private JsonObject buildMapAsJson(Map map)
{
// Convert the Map into a JsonObject
JsonObjectBuilder builder = Json.createObjectBuilder();
Iterator keysMap = map.keySet().iterator();
while (keysMap.hasNext()) {
String key = keysMap.next();
builder.add(key, map.get(key));
}
return builder.build();
}
/**
* Get the parent node type of a given cards:Answer node.
*
* @param answer A node corresponding to an cards:Answer
* @return A node corresponding to the parent subject type
*/
public Node getFormNode(Node answer)
{
try {
// Recursively go through our parents until we find a cards:Form node
// If we somehow reach the top level, return an error
Node answerParent = answer.getParent();
while (answerParent != null && answerParent.getDepth() != 0
&& !"cards:Form".equals(answerParent.getPrimaryNodeType().getName())) {
answerParent = answerParent.getParent();
}
// If we never find a form by going upwards, this cards:Answer is malformed
if (answerParent.getDepth() == 0) {
LOGGER.warn("Tried to obtain the parent Form for node {} but failed to find one", answer.getPath());
return null;
}
return answerParent;
} catch (RepositoryException e) {
LOGGER.warn("Failed to access Form: {}", e.getMessage());
return null;
}
}
}