
io.uhndata.cards.PaginationServlet 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;
import java.io.IOException;
import java.io.Writer;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.jcr.ItemNotFoundException;
import javax.jcr.Node;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.query.Query;
import javax.jcr.query.QueryManager;
import javax.jcr.query.QueryResult;
import javax.jcr.query.Row;
import javax.jcr.query.RowIterator;
import javax.json.Json;
import javax.json.JsonObject;
import javax.json.stream.JsonGenerator;
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.SlingSafeMethodsServlet;
import org.apache.sling.servlets.annotations.SlingServletResourceTypes;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A servlet that lists resources of a specific type, depending on which "homepage" resource the request is targeting.
*
* This servlet supports the following parameters:
*
*
* offset
: a 0-based number representing how many resources to skip; 0 by default
* limit
: a number representing how many resources to include at most in the result; 10 by default
* filter
: a (lucene-like) search term, such as {@code germline}, {@code cancer OR tumor},
* {@code (*blastoma OR *noma OR tumor*) recurrent}; no filter set by default
* includeallstatus
: if true, incomplete forms will be included. Otherwise, they will be excluded
* unless searched for directly using {@code fieldname="statusFlags"}
*
*
* @version $Id: 0476d08a3f1f8a0840db53fc7b65e2825ad96ed1 $
*/
@Component(service = { Servlet.class })
@SlingServletResourceTypes(
resourceTypes = { "cards/ResourceHomepage" },
selectors = { "paginate" })
public class PaginationServlet extends SlingSafeMethodsServlet
{
protected static final String FIELDNAME = "fieldname";
protected static final String FIELDCOMPARATOR = "fieldcomparator";
protected static final String FIELDVALUE = "fieldvalue";
private static final Logger LOGGER = LoggerFactory.getLogger(PaginationServlet.class);
private static final long serialVersionUID = -6068156942302219324L;
private static final int QUERY_SIZE_MULTIPLIER = 10;
// Allowed JCR-SQL2 operators (from https://docs.adobe.com/docs/en/spec/jcr/2.0/6_Query.html#6.7.17%20Operator)
private static final List COMPARATORS =
Arrays.asList("=", "<>", "<", "<=", ">", ">=", "LIKE", "notes contain", "contains", " IS NULL",
" IS NOT NULL");
private static final String SUBJECT_IDENTIFIER = "cards:Subject";
private static final String QUESTIONNAIRE_IDENTIFIER = "cards:Questionnaire";
private static final String CREATED_DATE_IDENTIFIER = "cards:Created";
private static final String CREATED_BY_IDENTIFIER = "cards:CreatedBy";
private static final String MODIFIED_BY_IDENTIFIER = "cards:LastModifiedBy";
private static final String MODIFIED_DATE_IDENTIFIER = "cards:LastModified";
private static final List NODE_FILTERS = Arrays.asList(CREATED_DATE_IDENTIFIER, CREATED_BY_IDENTIFIER,
MODIFIED_BY_IDENTIFIER, MODIFIED_DATE_IDENTIFIER);
/**
* Various supported filter types.
*/
protected enum FilterType
{
/** Regular filters on child node values. */
CHILD("child", "filternames", null, false),
/** IS NULL filters that check that a child node value is null. */
EMPTY("empty", "filterempty", " IS NULL", true),
/** IS NOT NULL filters that check that a child node value is not null. */
NOT_EMPTY("notempty", "filternotempty", " IS NOT NULL", true);
/** Prefix to use in query source names. */
private final String sourcePrefix;
/** The name of the request parameter holding the filters. */
private final String parameterName;
/** The default comparator to use, when no comparator is specified in the request. */
private final String comparator;
/** Whether this is a valueless filter, like "is null". */
private final boolean valueless;
FilterType(final String prefix, final String parameterName, final String comparator, final boolean valueless)
{
this.sourcePrefix = prefix;
this.parameterName = parameterName;
this.comparator = comparator;
this.valueless = valueless;
}
}
/**
* A parsed filter, gathering together the field, comparator, value to compare against, type of the value, and
* source name that the field belongs to.
*/
protected static final class Filter
{
/**
* The field name, may be the jcr:uuid of a question being answered, or a special value like the subject,
* creator, creation date, or questionnaire.
*/
private final String name;
/**
* The value to compare against, may be an empty string if no value is needed, e.g. for a "is null" filter.
*/
private final String value;
/** The type of the field/value, useful for special treatment of dates and booleans. */
private final String type;
/** The comparator, one of the COMPARATORS. */
private final String comparator;
/**
* The JCR query source name that the filter applies to. While other fields are parsed from the request, this
* will be filled in later when the query sources are generated.
*/
private String source;
/**
* The JCR node type of the source, e.g. {@code cards:BooleanAnswer}. While other fields are parsed from the
* request, this will be filled in later when the query sources are generated. Special filters will not have a
* node type at all, since they apply to a special known source.
*/
private String nodeType;
Filter(final String name, final String value, final String type, final String comparator)
{
this.name = name;
this.value = "date".equals(type) ? formatDate(value) : value;
this.type = type;
this.comparator = comparator;
}
String getName()
{
return this.name;
}
String getValue()
{
return this.value;
}
String getComparator()
{
return this.comparator;
}
private String formatDate(String original)
{
String result = original;
if (result.length() <= 10) {
// Just the date append the time and timezone
result += "T00:00:00.000" + DateUtils.getTimezoneForDateString(original);
} else if (result.length() == 16) {
// Date and time up to the minute, append seconds, millis and timezone
result += ":00.000" + DateUtils.getTimezoneForDateString(original);
} else if (result.length() == 19) {
// Date and time up to the second, append millis and timezone
result += ".000" + DateUtils.getTimezoneForDateString(original);
} else if (result.length() == 23) {
// Date and time, append the timezone
result += DateUtils.getTimezoneForDateString(original);
}
return result;
}
}
@Override
public void doGet(final SlingHttpServletRequest request, final SlingHttpServletResponse response)
throws IOException, IllegalArgumentException
{
try {
final ResourceResolver resolver = request.getResourceResolver();
final Session session = resolver.adaptTo(Session.class);
// Parse the request to build a list of filters
final Map> filters = parseFiltersFromRequest(request);
final long limit = getLongValueOrDefault(request.getParameter("limit"), 10);
final long offset = getLongValueOrDefault(request.getParameter("offset"), 0);
// Check for special cases in request and return zero results if any
if (checkForSpecialEmptyFilter(request, filters, response)) {
// Write the empty response
writeEmptyResponse(request, response, offset, limit);
return;
}
// Get a QueryManager object
final QueryManager queryManager = session.getWorkspace().getQueryManager();
// Create the Query object
Query filterQuery = queryManager.createQuery(createQuery(request, session, filters), "JCR-SQL2");
// Get the results and write the response
writeResponse(request, response, offset, limit, filterQuery);
} catch (Exception e) {
LOGGER.warn("Failed to execute query: {}", e.getMessage(), e);
return;
}
}
/**
* Checks if any "is empty" filters for the mandatory data are specified, since such a query will never have any
* results.
*
* @param request the current request
* @param filters a list of filters
* @param response the HTTP response
* @return {@code true} if any mandatory special property has an "is empty" filter, {@code false} otherwise
* @throws RepositoryException if accessing the repository fails
*/
protected boolean checkForSpecialEmptyFilter(final SlingHttpServletRequest request,
final Map> filters, final SlingHttpServletResponse response)
throws RepositoryException
{
for (Filter filter : filters.getOrDefault(FilterType.EMPTY, new ArrayList())) {
if (SUBJECT_IDENTIFIER.equals(filter.name)
|| QUESTIONNAIRE_IDENTIFIER.equals(filter.name)
|| NODE_FILTERS.contains(filter.name)) {
return true;
}
}
return false;
}
/**
* Write an empty results response.
*
* @param request the current request
* @param response the HTTP response
* @param offset how many resources from the query results were skipped
* @param limit how many resources from the query results to serialize, may be 0 if we only want a count of the
* resources
* @throws IOException if failed or interrupted I/O operation
* @throws RepositoryException if accessing the repository fails
*/
private void writeEmptyResponse(final SlingHttpServletRequest request, final SlingHttpServletResponse response,
final long offset, final long limit)
throws IOException, RepositoryException
{
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
// The writer doesn't need to be explicitly closed since the auto-closed jsonGen will also close the writer
final Writer out = response.getWriter();
try (JsonGenerator jsonGen = Json.createGenerator(out)) {
jsonGen.writeStartObject();
jsonGen.writeStartArray("rows").writeEnd();
long[] limits = new long[] { offset, limit, 0, 0, 0 };
writeSummary(jsonGen, request, limits);
jsonGen.writeEnd().flush();
}
}
/**
* Write the response.
*
* @param request the current request
* @param response the HTTP response
* @param offset how many resources from the query results were skipped
* @param limit how many resources from the query results to serialize, may be 0 if we only want a count of the
* resources
* @param query the query to execute
* @throws IOException if failed or interrupted I/O operation
* @throws RepositoryException if accessing the repository fails
*/
private void writeResponse(final SlingHttpServletRequest request, final SlingHttpServletResponse response,
final long offset, final long limit, final Query query)
throws IOException, RepositoryException
{
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
// The writer doesn't need to be explicitly closed since the auto-closed jsonGen will also close the writer
final Writer out = response.getWriter();
try (JsonGenerator jsonGen = Json.createGenerator(out)) {
jsonGen.writeStartObject();
jsonGen.writeStartArray("rows");
long[] limits = writeResources(jsonGen, query, offset, limit, request);
jsonGen.writeEnd();
writeSummary(jsonGen, request, limits);
jsonGen.writeEnd().flush();
}
}
/**
* Returns a type of results to return, a node type like {@code cards:Form} or {@code cards:Subject}.
*
* @param request the current request
* @return a node type string
* @throws RepositoryException if accessing the repository fails
*/
private String getNodeType(final SlingHttpServletRequest request) throws RepositoryException
{
final Node node = request.getResource().adaptTo(Node.class);
return node.hasProperty("childNodeType") ? node.getProperty("childNodeType").getString()
: request.getResource().getResourceType().replace('/', ':').replaceFirst("sHomepage$", "");
}
/**
* Generates a JCR SQL Query from the request.
*
* @param request the current request
* @param session a valid JCR session
* @param filters a list of filters
* @return a query that takes into account the requested filters
* @throws RepositoryException if accessing the repository fails
*/
protected String createQuery(final SlingHttpServletRequest request, Session session,
final Map> filters) throws RepositoryException
{
// If we want this query to be fast, we need to use the exact nodetype requested.
final String nodeType = getNodeType(request);
// We select all nodes having the right type
final StringBuilder query =
new StringBuilder("select distinct n.[jcr:uuid] from [").append(nodeType).append("] as n");
// The map that stores questionnaires uuids with prefixes in array and maps it to the
// corresponding group of questions uuids with their prefixes (stored in a map)
// To be used later on the filter query construction
// Add sources
query.append(
getQuerySources(
nodeType,
filters,
session));
// Check only for the descendants of the requested homepage
query.append(" where isdescendantnode(n, '" + request.getResource().getPath() + "')");
// Full text search; \ and ' must be escaped
final String fullTextFilter = request.getParameter("filter");
if (StringUtils.isNotBlank(fullTextFilter)) {
query.append(" and contains(n.*, '" + this.sanitizeValue(fullTextFilter) + "')");
}
// Exact condition on parent node; \ and ' must be escaped. The value must be wrapped in 's
Map fieldParameters = getSanitizedFieldParameters(request);
if (StringUtils.isNotBlank(fieldParameters.get(FIELDNAME))) {
query.append(String.format(
" and n.'%s'%s'%s'",
fieldParameters.get(FIELDNAME),
fieldParameters.get(FIELDCOMPARATOR),
fieldParameters.get(FIELDVALUE)));
}
// TODO, if more request options are required: convert includeAllStatus into a request mode
// backed by an enum
final boolean includeAllStatus = Boolean.parseBoolean(request.getParameter("includeallstatus"));
// Only display `INCOMPLETE` forms if we are explicitly checking the status of forms,
// or if the user requested forms with all statuses
if (!("statusFlags".equals(fieldParameters.get(FIELDNAME)) || includeAllStatus
|| nodeType.equals(SUBJECT_IDENTIFIER))) {
query.append(" and not n.'statusFlags'='INCOMPLETE'");
}
// Conditions on child nodes
query.append(getQueryConditions(nodeType, filters));
// Results ordering
final boolean sortDescending = Boolean.valueOf(request.getParameter("descending"));
query.append(" order by n.'jcr:created'").append(sortDescending ? " DESC" : " ASC");
// Force using the lucene indexes
query.append(" option(index tag cards)");
// All done!
String finalquery = query.toString();
LOGGER.debug("Computed final query: {}", finalquery);
return finalquery;
}
/**
* Get from the request field parameters, sanitize and parse them into a collection.
*
* @param request the current request
* @return a map from field parameter name to field parameter value
*/
protected Map getSanitizedFieldParameters(final SlingHttpServletRequest request)
{
Map sanitizedFilterParameters = new HashMap<>();
sanitizedFilterParameters.put(FIELDNAME, this.sanitizeValue(request.getParameter(FIELDNAME)));
sanitizedFilterParameters.put(FIELDVALUE, this.sanitizeValue(request.getParameter(FIELDVALUE)));
sanitizedFilterParameters.put(FIELDCOMPARATOR, this.sanitizeComparator(request.getParameter(FIELDCOMPARATOR)));
return sanitizedFilterParameters;
}
/**
* Parse the request parameters into a collection of proper filters.
*
* @param request the current request
* @return a map from filter types to a list of filters of that type; the resulting map may be empty, or it may have
* only some types of filters, depending on which filters are specified in the request
* @throws IllegalArgumentException when the number of request parameters are not equal
*/
protected Map> parseFiltersFromRequest(final SlingHttpServletRequest request)
throws IllegalArgumentException
{
final Map> result = new HashMap<>();
for (FilterType filterType : FilterType.values()) {
final String[] filters = request.getParameterValues(filterType.parameterName);
if (filters == null || filters.length == 0) {
continue;
}
if (filterType.valueless) {
result.put(filterType,
Arrays.asList(filters).stream().map(f -> new Filter(f, "", "", filterType.comparator))
.collect(Collectors.toList()));
} else {
// FIXME This only works with one filter, it should be refactored to use a common prefix + suffixes
final String[] values = request.getParameterValues("filtervalues");
final String[] types = request.getParameterValues("filtertypes");
final String[] comparators = request.getParameterValues("filtercomparators");
if (filters.length != values.length || types.length != comparators.length
|| filters.length != comparators.length) {
throw new IllegalArgumentException(
"Invalid request, the same number of filter names, values, types and comparators"
+ " must be provided");
}
final List gatheredFilters = new LinkedList<>();
for (int i = 0; i < filters.length; ++i) {
gatheredFilters.add(new Filter(filters[i], values[i], types[i], comparators[i]));
}
result.put(filterType, gatheredFilters);
}
}
return result;
}
/**
* Processes all the filters and creates the query's source.
*
* @param nodeType the type of results to return, a node type like {@code cards:Form} or {@code cards:Subject}
* @param filters a list of filters
* @param session the current JCR session
* @return the query fragment listing all sub-sources except the main node type itself (the "join ..." part); empty
* if there are no filters for descendant nodes
*/
private String getQuerySources(final String nodeType,
final Map> filters, final Session session)
{
final Map questionnairesToFormSource = new HashMap<>();
final Map> questionnairesToQuestions = new HashMap<>();
// Resolve all questions to questionnaires and group them by questionnaires
filters.forEach((type, values) -> mapFiltersToSources(nodeType, type, values, questionnairesToFormSource,
questionnairesToQuestions, session));
if (nodeType.equals(SUBJECT_IDENTIFIER)) {
// Make joins per questionnaire/form, and per each question/answer in a form
return createSubjectJoins(questionnairesToFormSource, questionnairesToQuestions);
} else {
// There should be only one questionnaire in the end, but there's no way to enforce this in the UI.
// If there's more than one questionnaire involved, then no form will ever match.
// Just assume that all the questions belong to the same questionnaire, and append joins for answers for
// each question, regardless of the questionnaire
return createFormJoins(questionnairesToFormSource, questionnairesToQuestions);
}
}
/**
* Maps filter names to source names in the JCR query. This also fills in the source member of each filter.
*
* @param nodeType the type of results to return, a node type like {@code cards:Form} or {@code cards:Subject}
* @param filterType the type of filter to process
* @param filters a list of filters
* @param questionnairesToFormSource out parameter, maps from a questionnaire's UUID to a form source identifier, in
* the format {@code f1}
* @param questionnairesToQuestions out parameter, maps from a questionnaire's UUID to a list of questions that
* belong to it
* @param questionsToAnswerSource out parameter, maps from a question UUID to an answer source identifier, in the
* format {@code child1_1}, where {@code child} is the specified prefix, the first number is the form
* source number, and the second number is a counter of the sub-sources belonging to the same form
* @param session the current JCR session
*/
private void mapFiltersToSources(
final String nodeType, final FilterType filterType, final List filters,
final Map questionnairesToFormSource,
final Map> questionnairesToQuestions,
final Session session)
{
if (filters == null) {
return;
}
for (Filter filter : filters) {
if (SUBJECT_IDENTIFIER.equals(filter.name) || NODE_FILTERS.contains(filter.name)) {
// For special node filters, all we need to do is record the source name in the filter
filter.source = "n";
continue;
}
String questionnaire = "";
if (QUESTIONNAIRE_IDENTIFIER.equals(filter.name)) {
// When filtering explicitly by questionnaire, we already know the questionnaire for the filter
questionnaire = filter.value;
} else {
// Otherwise, we look it up from the question
questionnaire = getQuestionnaire(filter.name, session);
}
if (StringUtils.isNotBlank(questionnaire)) {
// If this is the first time we encounter a questionnaire, add a new source for it
questionnairesToFormSource.computeIfAbsent(questionnaire,
k -> "f" + (questionnairesToFormSource.size() + 1));
if (QUESTIONNAIRE_IDENTIFIER.equals(filter.name)) {
if (SUBJECT_IDENTIFIER.equals(nodeType)) {
// The source for a questionnaire filter is one of the forms relating to the subject
filter.source = questionnairesToFormSource.get(questionnaire);
} else {
// The source for a questionnaire filter is the form node itself
filter.source = "n";
}
// Not a question, no need to update the list of questions
continue;
}
List questions =
questionnairesToQuestions.computeIfAbsent(questionnaire, k -> new ArrayList<>());
questions.add(filter);
// Filters count for this questionnaire
String fcount = Integer.toString(questions.size());
// Update the source name in the filter, e.g. "childf1_1"
filter.source = filterType.sourcePrefix + questionnairesToFormSource.get(questionnaire) + "_" + fcount;
// Update the source node type in the filter, e.g. "cards:BooleanAnswer"
filter.nodeType = getAnswerNodeType(filter, session);
}
}
}
private String getAnswerNodeType(final Filter filter, final Session session)
{
String valueType = filter.type;
if (StringUtils.isBlank(valueType)) {
try {
valueType = session.getNodeByIdentifier(filter.name).getProperty("dataType").getString();
} catch (RepositoryException e) {
LOGGER.warn("{}", e.getMessage(), e);
}
}
return StringUtils.isBlank(valueType) ? "cards:Answer"
: "cards:" + valueType.substring(0, 1).toUpperCase(Locale.ROOT) + valueType.substring(1)
+ "Answer";
}
/**
* Computes the joins needed for the filters, when the targeted node type is {@code cards:Form}. This means that for
* each question a new {@code cards:Answer} source to the sources on the condition that it belongs to the targeted
* node.
*
* @param questionnairesToFormSource maps from a questionnaire's UUID to a form source identifier
* @param questionnairesToFilters maps from a questionnaire's UUID to a list of answer filters that belong to it
* @return the query fragment listing all sub-sources except the main node type itself (the "join ..." part); empty
* if there are no filters for descendant nodes
*/
private String createFormJoins(Map questionnairesToFormSource,
Map> questionnairesToFilters)
{
StringBuilder joins = new StringBuilder();
for (String questionnaire : questionnairesToFormSource.keySet()) {
final List filtersInQuestionnaire =
questionnairesToFilters.getOrDefault(questionnaire, Collections.emptyList());
for (Filter filter : filtersInQuestionnaire) {
final String answerSource = filter.source;
joins.append(
String.format(
" inner join [%s] as %s on %s.form = n.[jcr:uuid]",
filter.nodeType,
answerSource,
answerSource));
}
}
return joins.toString();
}
/**
* Computes the joins needed for the filters, when the targeted node type is {@code cards:Subject}. This means that
* for each questionnaire involved in the filters a new {@code cards:Form} is added to the sources on the condition
* that the targeted subject is one of its {@code relatedSubjects}, and for each question a new {@code cards:Answer}
* source to the sources on the condition that it belongs to the correct form source.
*
* @param questionnairesToFormSource maps from a questionnaire's UUID to a form source identifier
* @param questionnairesToFilters maps from a questionnaire's UUID to a list of answer filters that belong to it
* @return the query fragment listing all sub-sources except the main node type itself (the "join ..." part); empty
* if there are no filters for descendant nodes
*/
private String createSubjectJoins(final Map questionnairesToFormSource,
final Map> questionnairesToFilters)
{
StringBuilder joins = new StringBuilder();
for (String questionnaire : questionnairesToFormSource.keySet()) {
final String formSource = questionnairesToFormSource.get(questionnaire);
joins.append(
String.format(
" inner join [cards:Form] as %s on n.[jcr:uuid] = %s.relatedSubjects",
formSource,
formSource));
final List filters =
questionnairesToFilters.getOrDefault(questionnaire, Collections.emptyList());
for (Filter filter : filters) {
final String answerSource = filter.source;
joins.append(
String.format(
" inner join [%s] as %s on %s.[jcr:uuid] = %s.form",
filter.nodeType,
answerSource,
formSource,
answerSource));
}
}
return joins.toString();
}
/**
* Generates a date comparison query that takes into account correct day boundaries. In the content repository dates
* are stored as date-time values, using a fixed timezone, so ignoring the time part and only selecting dates that
* would match the timezone from the client must be done by explicitly comparing against custom day start and day
* end times.
*
* @param queryProperty a query property to compare in the format {@code n.'jcr:created'} or {@code child1_2.value}
* @param operator the operator to use, one of {@code = <> < > <= or >=}
* @param valueToCompare a date string to compare against, including a timezone offset, in the format
* {@code 2020-12-31T00:00-04:00}
* @return a query fragment that imposes the correct conditions on the property, for example
* {@code (n.'jcr:created'>='2020-12-31T00:00:00-04:00' and n.'jcr:created'<'2021-01-01T00:00-04:00')}
*/
private String generateDateCompareQuery(final String queryProperty, final String operator,
final String valueToCompare)
{
//
// thisDay = start of day in a custom timezone
// nextDay = thisDay + 1 day
// IF (=) THEN CHECK (>= thisDay AND < nextDay)
// IF (<>) THEN CHECK (< thisDay OR >= nextDay)
// IF (<) THEN CHECK (< thisDay)
// IF (>) THEN CHECK (>= nextDay)
// IF (<=) THEN CHECK (< nextDay)
// IF (>=) THEN CHECK (>= thisDay)
//
final ZonedDateTime thisDay = ZonedDateTime.parse(valueToCompare);
final ZonedDateTime nextDay = thisDay.plusDays(1);
final String nextDayStr = nextDay.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx"));
String compareQuery;
switch (operator) {
case "=":
compareQuery = String.format("(%s>='%s' and %s<'%s')",
queryProperty,
valueToCompare,
queryProperty,
nextDayStr);
break;
case "<>":
compareQuery = String.format("(%s<'%s' or %s>='%s')",
queryProperty,
valueToCompare,
queryProperty,
nextDayStr);
break;
case "<":
compareQuery = String.format("(%s<'%s')",
queryProperty,
valueToCompare);
break;
case ">":
compareQuery = String.format("(%s>='%s')",
queryProperty,
nextDayStr);
break;
case "<=":
compareQuery = String.format("(%s<'%s')",
queryProperty,
nextDayStr);
break;
case ">=":
compareQuery = String.format("(%s>='%s')",
queryProperty,
valueToCompare);
break;
default:
compareQuery = null;
break;
}
return compareQuery;
}
/**
* Parse out filter data into a series of JCR_SQL2 conditionals.
*
* @param nodeType the targeted node type, e.g. {@code cards:Form} or {@code cards:Subject}
* @param filters the list of filters
*/
private String getQueryConditions(final String nodeType, final Map> filters)
{
StringBuilder conditions = new StringBuilder();
for (Map.Entry> filtersOfType : filters.entrySet()) {
for (Filter filter : filtersOfType.getValue()) {
// Add special conditions for the target subject and the created date
// When the targeted node type is subjects, the subject filter applies to the target node itself
// When the targeted node type is forms, the subject filter applies to the form's relatedSubjects
// property
conditions.append(
addSpecialCondition(filter, nodeType.equals(SUBJECT_IDENTIFIER) ? "jcr:uuid" : "relatedSubjects"));
// Add answer conditions
conditions.append(addSingleCondition(filter));
}
}
return conditions.toString();
}
/**
* Converts a single special filter into a query condition, including the starting " and ". A special filter is one
* that applies to a unique node property, not to an answer value, for example the related subject, the answered
* questionnaire, or the node's creation date.
*
* @param filter a filter object
* @param subjectProperty the property that references the subject, either {@code jcr:uuid} if the targeted node is
* actually a subject, or {@code reltedSubjects} if the targeted node is forms
* @return a query condition, for example {@code and n.'questionnaire'='e6b97318-f464-4bb2-82e8-b13df62d33f3'}
*/
private String addSpecialCondition(final Filter filter, final String subjectProperty)
{
StringBuilder filterdata = new StringBuilder();
switch (filter.name) {
case SUBJECT_IDENTIFIER:
filterdata.append(
String.format(" and %s.'%s'%s'%s'",
filter.source,
subjectProperty,
this.sanitizeComparator(filter.comparator),
this.sanitizeValue(filter.value)));
break;
case QUESTIONNAIRE_IDENTIFIER:
filterdata.append(
String.format(" and %s.'questionnaire'%s'%s'",
filter.source,
this.sanitizeComparator(filter.comparator),
this.sanitizeValue(filter.value)));
break;
case CREATED_DATE_IDENTIFIER:
filterdata.append(" and ");
filterdata.append(
generateDateCompareQuery(
"n.'jcr:created'",
this.sanitizeComparator(filter.comparator),
this.sanitizeValue(filter.value)));
break;
case CREATED_BY_IDENTIFIER:
filterdata.append(
String.format(" and n.'jcr:createdBy'%s'%s'",
this.sanitizeComparator(filter.comparator),
this.sanitizeValue(filter.value)));
break;
case MODIFIED_DATE_IDENTIFIER:
filterdata.append(" and ");
filterdata.append(
generateDateCompareQuery(
"n.'jcr:lastModified'",
this.sanitizeComparator(filter.comparator),
this.sanitizeValue(filter.value)));
break;
case MODIFIED_BY_IDENTIFIER:
filterdata.append(
String.format(" and n.'jcr:lastModifiedBy'%s'%s'",
this.sanitizeComparator(filter.comparator),
this.sanitizeValue(filter.value)));
break;
default:
break;
}
return filterdata.toString();
}
/**
* Converts a single value filter into a query condition, including the starting " and ", referencing the correct
* question, and comparing against the filter value.
*
* @param filter a filter object
* @return a query condition, for example {@code and notemptyf1_4.'question'='b5a6163e-db5a-4deb-822e-5dbe57031627'
* and notemptyf1_4.'value' IS NOT NULL}
*/
private String addSingleCondition(final Filter filter)
{
StringBuilder condition = new StringBuilder();
if (SUBJECT_IDENTIFIER.equals(filter.name)
|| QUESTIONNAIRE_IDENTIFIER.equals(filter.name)
|| NODE_FILTERS.contains(filter.name)) {
return "";
}
// Condition 1: the question uuid must match
condition.append(
String.format(
" and %s.'question'='%s'",
filter.source,
this.sanitizeValue(filter.name)));
// Condition 2: the value must match
if ("contains".equals(filter.comparator)) {
condition.append(
String.format(
" and contains(%s.'value', '*%s*')",
filter.source,
this.sanitizeValue(filter.value)));
} else if ("notes contain".equals(filter.comparator)) {
condition.append(
String.format(
" and contains(%s.'note', '*%s*')",
filter.source,
this.sanitizeValue(filter.value)));
} else {
condition.append(" and");
if ("<>".equals(filter.comparator)) {
condition.append("(")
.append(getValueComparisonString(filter))
.append(
String.format(
" or %s.'value' IS NULL)",
filter.source));
} else {
condition.append(getValueComparisonString(filter));
}
}
return condition.toString();
}
private String getValueComparisonString(Filter filter)
{
return String.format(
" %s.'value'%s" + ("boolean".equals(filter.type) ? "%s"
: StringUtils.isNotBlank(filter.value) ? "'%s'" : ""),
filter.source,
this.sanitizeComparator(filter.comparator),
this.sanitizeValue(filter.value));
}
/**
* Sanitize a field name or value for an input query by escaping special query characters.
*
* @param input the value to sanitize
* @return a sanitized version of the input
*/
private String sanitizeValue(String input)
{
return StringUtils.isEmpty(input) ? "" : input.replaceAll("['\\\\]", "\\\\$0");
}
/**
* Sanitize a comparator for an input query by only accepting a predefined set of comparators, using {@code =}
* instead of unknown comparators.
*
* @param comparator the comparator to sanitize
* @return an accepted comparator, may be {@code =} if the specified comparator is not supported
*/
private String sanitizeComparator(String comparator)
{
if (!COMPARATORS.contains(comparator)) {
// Invalid comparator: return '='
return "=";
}
return comparator;
}
/**
* Write metadata about the request and response. This includes the number of returned and total matching nodes, and
* copying some request parameters.
*
* @param jsonGen the JSON generator where the results should be serialized
* @param request the current request
* @param limits an array of values defining the range of the result: limits[0] is the 0-based offset, i.e. how many
* results were skipped; limits[1] is the requested limit, the maximum number of results to return;
* limits[2] is the number of results actually returned, equal to or less than limits[1]; limits[3] is an
* approximate number of total items that match the query
*/
private void writeSummary(final JsonGenerator jsonGen, final SlingHttpServletRequest request, final long[] limits)
{
final boolean totalIsApproximate = limits[4] == 1;
jsonGen.write("req", request.getParameter("req"));
jsonGen.write("offset", limits[0]);
jsonGen.write("limit", limits[1]);
jsonGen.write("returnedrows", limits[2]);
jsonGen.write("totalrows", limits[3]);
jsonGen.write("totalIsApproximate", totalIsApproximate);
}
/**
* Serialize the query results into the response JSON. Since JCR queries don't support an easy way to compute the
* total number of matches, the query result may contain more resources than actually requested. This method also
* accepts a limit, and only at most that many resources will actually be included in the response.
*
* @param jsonGen the JSON generator where the results should be serialized
* @param query the query to execute
* @param resultOffset how many resources from the query results were skipped
* @param resultLimit how many resources from the query results to serialize, may be 0 if we only want a count of
* the resources
* @param request the current request
* @return an array of values defining the range of the result: limits[0] is the 0-based offset, i.e. how many
* results were skipped; limits[1] is the requested limit, the maximum number of results to return;
* limits[2] is the number of results actually returned, equal to or less than limits[1]; limits[3] is an
* approximate number of total items that match the query
*/
private long[] writeResources(final JsonGenerator jsonGen, final Query query,
final long resultOffset, final long resultLimit, final SlingHttpServletRequest request)
{
// Problem 1: Currently Oak does not support DISTINCT, so we must manually ensure uniqueness of the results.
// Problem 2: Currently Oak does not support giving a total number of matches, so we must gauge it.
// - Request all results
// - Deduplicate the results as we process them
// - Keep a count of how many unique results were skipped so far, until the requested "offset" items are skipped
// - After skipping "offset" items, output at most "limit" unique items to the resulting JSON
// - After outputting "limit" items, keep counting how many new unique items are there until there are no more
// results, or encounter 10 more pages of unique items, rounded up to a whole 10*page batch
final String selectors =
(request.getParameter("resourceSelectors") == null ? "" : "." + request.getParameter("resourceSelectors"))
.replaceAll("\\.\\.", ".");
// The returned number of items
long returnedResults = 0;
// If there are more results that haven't been counted
long andMore = 0;
// Which unique items have been seen so far in the query results
final Set seenResources = new HashSet<>();
// Termination condition limit: when to stop looking for new unique results
// We consider (10 * the page size) a batch, e.g. 100 for a 10-page, 250 for a 25-page
// We count up to a whole number of batches
// We go up until the batch that fully contains the batch that would start at the requested offset
// However, we include just one more result to see if there are "more than" results
// For example:
// - if the page size is 10 and the offset is 0, we stop at 101
// - if the page size is 10 and the offset is 110, we stop at 301
// - if the page size is 25 and the offset is 500, we stop at 751
// - if the page size is 25 and the offset is 501, we stop at 1001
// Batch size:
long totalLimit = (QUERY_SIZE_MULTIPLIER * resultLimit);
// Batch fully containing the requested page:
totalLimit = (((long) Math.ceil(((double) resultOffset) / ((double) totalLimit))) + 1) * totalLimit;
// And one more for the "more than" check:
++totalLimit;
// How many more items to include in the output
long limitCounter = resultLimit < 0 ? 0 : resultLimit;
// Execute the query
try {
final QueryResult filterResult = query.execute();
final RowIterator rows = filterResult.getRows();
while (rows.hasNext()) {
final Row row = rows.nextRow();
final String path = row.getPath();
// If this resource was already seen, ignore it
if (!seenResources.contains(path)) {
seenResources.add(path);
// If we've passed the "offset" mark, and we didn't output "limit" items yet, include the
// resource in the output
if (seenResources.size() > resultOffset && limitCounter > 0) {
jsonGen.write(
request.getResourceResolver().resolve(path + selectors).adaptTo(JsonObject.class));
--limitCounter;
++returnedResults;
}
if (seenResources.size() >= totalLimit) {
break;
}
}
}
} catch (RepositoryException e) {
//
}
// We included one extra result in the total limit, check if we went that far
if (seenResources.size() == totalLimit) {
andMore = 1;
}
return new long[] {
// The requested offset
resultOffset,
// The requested number of items
resultLimit,
// The returned number of items
returnedResults,
// The total number of items matching the query
seenResources.size() - andMore,
// There are probably more results than seen
andMore
};
}
/**
* Convert a request parameter, which may be missing or invalid, into a proper long, with fallback to a default
* value.
*
* @param stringValue the string to convert, expected to be a proper number, but may be {@code null} or not a proper
* number
* @param defaultValue the default value to use if the input string cannot be converted to a number
* @return a number, either the parsed input, if valid, or the default value
*/
private long getLongValueOrDefault(final String stringValue, final long defaultValue)
{
long value = defaultValue;
try {
value = Long.parseLong(stringValue);
} catch (NumberFormatException exception) {
value = defaultValue;
}
return value;
}
/**
* Retrieves the UUID of the questionnaire that the given question belongs to.
*
* @param questionUuid the UUID of a question
* @param session the current JCR session
* @return an UUID, if the input UUID does correspond to a valid, accessible question that belongs to a
* questionnaire, or the empty string otherwise
*/
private String getQuestionnaire(String questionUuid, Session session)
{
if (session == null) {
LOGGER.warn("Could not match questionnaire UUID {}: session not found.", questionUuid);
return "";
}
try {
Node parent = session.getNodeByIdentifier(questionUuid);
while (parent.getParent() != null) {
parent = parent.getParent();
if (parent.isNodeType(QUESTIONNAIRE_IDENTIFIER)) {
return parent.getProperty("jcr:uuid").getString();
}
}
} catch (ItemNotFoundException e) {
LOGGER.debug("Questionnaire UUID {} is inaccessible", questionUuid, e);
} catch (RepositoryException e) {
LOGGER.error("Failed to find questionnaire UUID {}", questionUuid, e);
}
return "";
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy