Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,10 @@ public class CommonRequestParams {
private Set<String> eventStatus = new LinkedHashSet<>();

/**
* UID of the numeric dimension (a tracked entity attribute for tracked entity aggregate queries)
* to aggregate over. Resolved downstream. When omitted, an aggregate query counts instances.
* The numeric dimension to aggregate over in a tracked entity aggregate query: either a tracked
* entity attribute UID, or a program-stage data element in {@code
* programUid.programStageUid.dataElementUid} form. Resolved downstream. When omitted, an
* aggregate query counts instances.
*/
private String value;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,8 @@ public enum ErrorCode {
E7254("Aggregation type is not supported by tracked entity aggregate queries: `{0}`"),
E7255("Aggregation type `{0}` requires the `value` parameter"),
E7256("Value `{0}` is not a numeric tracked entity attribute of tracked entity type `{1}`"),
E7257(
"Value `{0}` does not reference a numeric data element of a program stage; expected format `programUid.programStageUid.dataElementUid`"),

/* Org unit analytics */
E7300(Constants.AT_LEAST_ONE_ORGANISATION_UNIT_MUST_BE_SPECIFIED),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2004-2026, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.analytics.trackedentity;

import org.hisp.dhis.dataelement.DataElement;
import org.hisp.dhis.program.ProgramStage;

/**
* The resolved program-stage event value an aggregate query aggregates over: a numeric data element
* of a program stage, collapsed to one chosen event per tracked entity. The {@code offset} selects
* which occurrence of the stage to read (0 = latest, negative = earlier, positive = ascending from
* the first), matching the row-level query offset semantics.
*/
public record EventValue(ProgramStage programStage, DataElement dataElement, int offset) {}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
*/
package org.hisp.dhis.analytics.trackedentity;

import static java.util.Collections.singleton;
import static java.util.UUID.randomUUID;
import static java.util.stream.Collectors.toCollection;
import static org.hisp.dhis.analytics.AnalyticsMetaDataKey.DIMENSIONS;
Expand All @@ -40,6 +39,7 @@
import static org.hisp.dhis.common.DimensionConstants.ORGUNIT_DIM_ID;

import java.math.BigDecimal;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
Expand All @@ -65,6 +65,7 @@
import org.hisp.dhis.common.ExecutionPlan;
import org.hisp.dhis.common.Grid;
import org.hisp.dhis.common.GridHeader;
import org.hisp.dhis.common.IdentifiableObject;
import org.hisp.dhis.common.IllegalQueryException;
import org.hisp.dhis.common.MetadataItem;
import org.hisp.dhis.common.ValueType;
Expand Down Expand Up @@ -122,7 +123,7 @@ public Grid getGrid(
CommonParsedParams commonParams = contextParams.getCommonParsed();
TrackedEntityQueryParams teParams = contextParams.getTypedParsed();

securityManager.decideAccess(commonParams, singleton(teParams.getTrackedEntityType()));
securityManager.decideAccess(commonParams, dataReadObjects(teParams));
securityManager.applyOrganisationUnitConstraint(commonParams);
securityManager.applyDimensionConstraints(commonParams);

Expand Down Expand Up @@ -155,6 +156,28 @@ public Grid getGrid(
return grid;
}

/**
* The objects whose data-read access must be checked beyond the query's dimensions: the tracked
* entity type, and — when aggregating over a program-stage data element value — that program
* stage. The event value is not a dimension identifier, so it is not covered by the
* dimension-based checks in {@link CommonParamsSecurityManager#decideAccess}; without adding the
* stage here a user with program/TET access but no data-read access to the stage could read its
* event values. The data element itself is intentionally not checked: event-data access is
* governed by the program stage, and data elements are in {@code CommonParamsSecurityManager}'s
* data-read skip set.
*/
private static Set<IdentifiableObject> dataReadObjects(TrackedEntityQueryParams teParams) {
Set<IdentifiableObject> objects = new HashSet<>();
objects.add(teParams.getTrackedEntityType());

EventValue eventValue = teParams.getEventValue();
if (eventValue != null) {
objects.add(eventValue.programStage());
}

return objects;
}

/**
* Scopes the metadata inputs to the dimensions the aggregate query actually groups by. The TE
* mapper injects all the tracked entity type's attributes into the parsed dimensions for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,18 @@ public class TrackedEntityQueryParams {

/**
* The numeric tracked entity attribute the aggregation function of an aggregate query applies to.
* When null, an aggregate query counts tracked entity instances.
* When null, the aggregate query either counts tracked entity instances or aggregates over an
* {@link #eventValue}. At most one of {@code attributeValue} and {@code eventValue} is non-null.
*/
private final TrackedEntityAttribute value;
private final TrackedEntityAttribute attributeValue;

/**
* The numeric program-stage data element the aggregation function of an aggregate query applies
* to, collapsed to one chosen event per tracked entity. When null, the aggregate query either
* counts tracked entity instances or aggregates over an {@link #attributeValue}. At most one of
* {@code attributeValue} and {@code eventValue} is non-null.
*/
private final EventValue eventValue;

/** The aggregation function applied to the value column of an aggregate query. */
private final AggregationType aggregationType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
import static org.hisp.dhis.feedback.ErrorCode.E7254;
import static org.hisp.dhis.feedback.ErrorCode.E7255;
import static org.hisp.dhis.feedback.ErrorCode.E7256;
import static org.hisp.dhis.feedback.ErrorCode.E7257;

import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
Expand All @@ -49,10 +51,14 @@
import org.apache.commons.lang3.Strings;
import org.hisp.dhis.analytics.AggregationType;
import org.hisp.dhis.analytics.common.CommonRequestParams;
import org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifierHelper;
import org.hisp.dhis.analytics.common.params.dimension.StringDimensionIdentifier;
import org.hisp.dhis.common.IdentifiableObject;
import org.hisp.dhis.common.IllegalQueryException;
import org.hisp.dhis.dataelement.DataElement;
import org.hisp.dhis.program.Program;
import org.hisp.dhis.program.ProgramService;
import org.hisp.dhis.program.ProgramStage;
import org.hisp.dhis.trackedentity.TrackedEntityAttribute;
import org.hisp.dhis.trackedentity.TrackedEntityType;
import org.hisp.dhis.trackedentity.TrackedEntityTypeService;
Expand Down Expand Up @@ -111,15 +117,38 @@ public TrackedEntityQueryParams map(
getTrackedEntityAttributes(trackedEntityType).map(IdentifiableObject::getUid).toList());

validateAggregationType(requestParams);
TrackedEntityAttribute value = resolveValue(requestParams, trackedEntityType);

List<Program> programs = List.copyOf(programService.getPrograms(requestParams.getProgram()));
TrackedEntityAttribute attributeValue = null;
EventValue eventValue = null;
String valueUid = requestParams.getValue();
if (valueUid != null) {
if (isProgramStageDataElement(valueUid)) {
eventValue = resolveEventValue(valueUid, programs);
} else {
attributeValue = resolveAttributeValue(valueUid, programs, trackedEntityType);
}
}

return TrackedEntityQueryParams.builder()
.trackedEntityType(trackedEntityType)
.value(value)
.aggregationType(aggregationTypeFor(requestParams.getAggregationType(), value))
.attributeValue(attributeValue)
.eventValue(eventValue)
.aggregationType(
aggregationTypeFor(
requestParams.getAggregationType(), attributeValue != null || eventValue != null))
.build();
}

/**
* A {@code value} referencing a program-stage data element is qualified with a program and a
* program stage ({@code programUid.programStageUid.dataElementUid}); a bare UID references a
* tracked entity attribute.
*/
private static boolean isProgramStageDataElement(String valueUid) {
return valueUid.indexOf(DimensionIdentifierHelper.DIMENSION_SEPARATOR) >= 0;
}

/**
* Validates the requested aggregation type: it must be one this endpoint supports, and a
* non-COUNT type requires a {@code value} to aggregate over.
Expand All @@ -144,26 +173,20 @@ private void validateAggregationType(CommonRequestParams requestParams) {
}

/**
* Resolves the requested value UID into a numeric attribute to aggregate over. The candidates are
* the tracked entity type's own attributes and the attributes of the requested programs — the
* same set the analytics_te table flattens into columns, so both resolve to a bare {@code
* t_1."<uid>"} column. Data elements and program indicators are not accepted.
* Resolves a bare value UID into a numeric attribute to aggregate over. The candidates are the
* tracked entity type's own attributes and the attributes of the requested programs — the same
* set the analytics_te table flattens into columns, so both resolve to a bare {@code t_1."<uid>"}
* column. Data elements and program indicators are not accepted here.
*
* @param requestParams the {@link CommonRequestParams} carrying the value UID and programs.
* @param valueUid the requested value UID.
* @param programs the requested programs.
* @param trackedEntityType the {@link TrackedEntityType}.
* @return the resolved {@link TrackedEntityAttribute}, or null when no value was requested.
* @return the resolved {@link TrackedEntityAttribute}.
* @throws IllegalQueryException if the UID does not refer to a numeric attribute of the tracked
* entity type or its programs.
*/
private TrackedEntityAttribute resolveValue(
CommonRequestParams requestParams, TrackedEntityType trackedEntityType) {
String valueUid = requestParams.getValue();
if (valueUid == null) {
return null;
}

List<Program> programs = List.copyOf(programService.getPrograms(requestParams.getProgram()));

private TrackedEntityAttribute resolveAttributeValue(
String valueUid, List<Program> programs, TrackedEntityType trackedEntityType) {
return Stream.concat(
getTrackedEntityAttributes(trackedEntityType), getProgramAttributes(programs))
.filter(attribute -> valueUid.equals(attribute.getUid()))
Expand All @@ -172,18 +195,69 @@ private TrackedEntityAttribute resolveValue(
.orElseThrow(() -> new IllegalQueryException(E7256, valueUid, trackedEntityType.getUid()));
}

/**
* Resolves a {@code programUid.programStageUid.dataElementUid} value into the numeric
* program-stage data element to aggregate over. The program must be one of the requested
* programs, the stage must belong to that program, and the data element must belong to that stage
* and be numeric. The stage segment may carry an offset ({@code [n]}) selecting which occurrence
* to read; an offset on the program segment is not allowed.
*
* @param valueUid the fully qualified value in {@code programUid.programStageUid.dataElementUid}
* form.
* @param programs the requested programs.
* @return the resolved {@link EventValue}.
* @throws IllegalQueryException if the value does not reference a numeric data element of a
* program stage of the requested programs.
*/
private EventValue resolveEventValue(String valueUid, List<Program> programs) {
StringDimensionIdentifier parsed;
try {
parsed = DimensionIdentifierHelper.fromFullDimensionId(valueUid);
} catch (IllegalArgumentException e) {
throw new IllegalQueryException(E7257, valueUid);
}

if (!parsed.getProgram().isPresent()
|| !parsed.getProgramStage().isPresent()
|| parsed.getProgram().hasOffset()) {
throw new IllegalQueryException(E7257, valueUid);
}

String programUid = parsed.getProgram().getElement().getUid();
String programStageUid = parsed.getProgramStage().getElement().getUid();
String dataElementUid = parsed.getDimension().getUid();
int offset = parsed.getProgramStage().getOffsetWithDefault();

ProgramStage programStage =
programs.stream()
.filter(program -> programUid.equals(program.getUid()))
.map(Program::getProgramStages)
.flatMap(Collection::stream)
.filter(stage -> programStageUid.equals(stage.getUid()))
.findFirst()
.orElseThrow(() -> new IllegalQueryException(E7257, valueUid));

DataElement dataElement =
programStage.getDataElements().stream()
.filter(de -> dataElementUid.equals(de.getUid()))
.filter(de -> de.getValueType().isNumeric())
.findFirst()
.orElseThrow(() -> new IllegalQueryException(E7257, valueUid));

return new EventValue(programStage, dataElement, offset);
}

/**
* Returns the aggregation function to apply: the requested one, falling back to AVERAGE when a
* value attribute is present — matching the event/enrollment aggregate contract. Without a value
* attribute the aggregate query counts TEIs, so no function is materialized.
* value is present — matching the event/enrollment aggregate contract. Without a value the
* aggregate query counts TEIs, so no function is materialized.
*/
private AggregationType aggregationTypeFor(
AggregationType requested, TrackedEntityAttribute value) {
private AggregationType aggregationTypeFor(AggregationType requested, boolean hasValue) {
if (requested != null) {
return requested;
}

return value != null ? AggregationType.AVERAGE : null;
return hasValue ? AggregationType.AVERAGE : null;
}

private Set<String> getProgramUidsFromTrackedEntityType(TrackedEntityType trackedEntityType) {
Expand Down
Loading
Loading