Skip to content

Commit fafa6c0

Browse files
feat: support program-stage data element values in TE aggregate analytics [DHIS2-21763]
Extend /api/analytics/trackedEntities/aggregate/{tet} so the value request param can be a program-stage data element (event value), not only a tracked entity attribute. Event data-element values are multi-valued per tracked entity (one row per event in analytics_te_event_<tet>), so a naive join fans out and makes GROUP BY count events instead of tracked entities. Collapse each TE's events for the stage to a single chosen row via row_number() and LEFT JOIN it back at TE grain.
1 parent 3717280 commit fafa6c0

13 files changed

Lines changed: 1312 additions & 41 deletions

File tree

dhis-2/dhis-api/src/main/java/org/hisp/dhis/analytics/common/CommonRequestParams.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,10 @@ public class CommonRequestParams {
204204
private Set<String> eventStatus = new LinkedHashSet<>();
205205

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

dhis-2/dhis-api/src/main/java/org/hisp/dhis/feedback/ErrorCode.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,8 @@ public enum ErrorCode {
619619
E7254("Aggregation type is not supported by tracked entity aggregate queries: `{0}`"),
620620
E7255("Aggregation type `{0}` requires the `value` parameter"),
621621
E7256("Value `{0}` is not a numeric tracked entity attribute of tracked entity type `{1}`"),
622+
E7257(
623+
"Value `{0}` does not reference a numeric data element of a program stage; expected format `programUid.programStageUid.dataElementUid`"),
622624

623625
/* Org unit analytics */
624626
E7300(Constants.AT_LEAST_ONE_ORGANISATION_UNIT_MUST_BE_SPECIFIED),
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.analytics.trackedentity;
31+
32+
import org.hisp.dhis.dataelement.DataElement;
33+
import org.hisp.dhis.program.ProgramStage;
34+
35+
/**
36+
* The resolved program-stage event value an aggregate query aggregates over: a numeric data element
37+
* of a program stage, collapsed to one chosen event per tracked entity. The {@code offset} selects
38+
* which occurrence of the stage to read (0 = latest, negative = earlier, positive = ascending from
39+
* the first), matching the row-level query offset semantics.
40+
*/
41+
public record EventValue(ProgramStage programStage, DataElement dataElement, int offset) {}

dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/trackedentity/TrackedEntityAggregateService.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
*/
3030
package org.hisp.dhis.analytics.trackedentity;
3131

32-
import static java.util.Collections.singleton;
3332
import static java.util.UUID.randomUUID;
3433
import static java.util.stream.Collectors.toCollection;
3534
import static org.hisp.dhis.analytics.AnalyticsMetaDataKey.DIMENSIONS;
@@ -40,6 +39,7 @@
4039
import static org.hisp.dhis.common.DimensionConstants.ORGUNIT_DIM_ID;
4140

4241
import java.math.BigDecimal;
42+
import java.util.HashSet;
4343
import java.util.LinkedHashMap;
4444
import java.util.LinkedHashSet;
4545
import java.util.List;
@@ -65,6 +65,7 @@
6565
import org.hisp.dhis.common.ExecutionPlan;
6666
import org.hisp.dhis.common.Grid;
6767
import org.hisp.dhis.common.GridHeader;
68+
import org.hisp.dhis.common.IdentifiableObject;
6869
import org.hisp.dhis.common.IllegalQueryException;
6970
import org.hisp.dhis.common.MetadataItem;
7071
import org.hisp.dhis.common.ValueType;
@@ -122,7 +123,7 @@ public Grid getGrid(
122123
CommonParsedParams commonParams = contextParams.getCommonParsed();
123124
TrackedEntityQueryParams teParams = contextParams.getTypedParsed();
124125

125-
securityManager.decideAccess(commonParams, singleton(teParams.getTrackedEntityType()));
126+
securityManager.decideAccess(commonParams, dataReadObjects(teParams));
126127
securityManager.applyOrganisationUnitConstraint(commonParams);
127128
securityManager.applyDimensionConstraints(commonParams);
128129

@@ -155,6 +156,28 @@ public Grid getGrid(
155156
return grid;
156157
}
157158

159+
/**
160+
* The objects whose data-read access must be checked beyond the query's dimensions: the tracked
161+
* entity type, and — when aggregating over a program-stage data element value — that program
162+
* stage. The event value is not a dimension identifier, so it is not covered by the
163+
* dimension-based checks in {@link CommonParamsSecurityManager#decideAccess}; without adding the
164+
* stage here a user with program/TET access but no data-read access to the stage could read its
165+
* event values. The data element itself is intentionally not checked: event-data access is
166+
* governed by the program stage, and data elements are in {@code CommonParamsSecurityManager}'s
167+
* data-read skip set.
168+
*/
169+
private static Set<IdentifiableObject> dataReadObjects(TrackedEntityQueryParams teParams) {
170+
Set<IdentifiableObject> objects = new HashSet<>();
171+
objects.add(teParams.getTrackedEntityType());
172+
173+
EventValue eventValue = teParams.getEventValue();
174+
if (eventValue != null) {
175+
objects.add(eventValue.programStage());
176+
}
177+
178+
return objects;
179+
}
180+
158181
/**
159182
* Scopes the metadata inputs to the dimensions the aggregate query actually groups by. The TE
160183
* mapper injects all the tracked entity type's attributes into the parsed dimensions for

dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/trackedentity/TrackedEntityQueryParams.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,18 @@ public class TrackedEntityQueryParams {
5353

5454
/**
5555
* The numeric tracked entity attribute the aggregation function of an aggregate query applies to.
56-
* When null, an aggregate query counts tracked entity instances.
56+
* When null, the aggregate query either counts tracked entity instances or aggregates over an
57+
* {@link #eventValue}. At most one of {@code attributeValue} and {@code eventValue} is non-null.
5758
*/
58-
private final TrackedEntityAttribute value;
59+
private final TrackedEntityAttribute attributeValue;
60+
61+
/**
62+
* The numeric program-stage data element the aggregation function of an aggregate query applies
63+
* to, collapsed to one chosen event per tracked entity. When null, the aggregate query either
64+
* counts tracked entity instances or aggregates over an {@link #attributeValue}. At most one of
65+
* {@code attributeValue} and {@code eventValue} is non-null.
66+
*/
67+
private final EventValue eventValue;
5968

6069
/** The aggregation function applied to the value column of an aggregate query. */
6170
private final AggregationType aggregationType;

dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/trackedentity/TrackedEntityQueryRequestMapper.java

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
import static org.hisp.dhis.feedback.ErrorCode.E7254;
3939
import static org.hisp.dhis.feedback.ErrorCode.E7255;
4040
import static org.hisp.dhis.feedback.ErrorCode.E7256;
41+
import static org.hisp.dhis.feedback.ErrorCode.E7257;
4142

43+
import java.util.Collection;
4244
import java.util.EnumSet;
4345
import java.util.List;
4446
import java.util.Objects;
@@ -49,10 +51,14 @@
4951
import org.apache.commons.lang3.Strings;
5052
import org.hisp.dhis.analytics.AggregationType;
5153
import org.hisp.dhis.analytics.common.CommonRequestParams;
54+
import org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifierHelper;
55+
import org.hisp.dhis.analytics.common.params.dimension.StringDimensionIdentifier;
5256
import org.hisp.dhis.common.IdentifiableObject;
5357
import org.hisp.dhis.common.IllegalQueryException;
58+
import org.hisp.dhis.dataelement.DataElement;
5459
import org.hisp.dhis.program.Program;
5560
import org.hisp.dhis.program.ProgramService;
61+
import org.hisp.dhis.program.ProgramStage;
5662
import org.hisp.dhis.trackedentity.TrackedEntityAttribute;
5763
import org.hisp.dhis.trackedentity.TrackedEntityType;
5864
import org.hisp.dhis.trackedentity.TrackedEntityTypeService;
@@ -111,15 +117,38 @@ public TrackedEntityQueryParams map(
111117
getTrackedEntityAttributes(trackedEntityType).map(IdentifiableObject::getUid).toList());
112118

113119
validateAggregationType(requestParams);
114-
TrackedEntityAttribute value = resolveValue(requestParams, trackedEntityType);
120+
121+
List<Program> programs = List.copyOf(programService.getPrograms(requestParams.getProgram()));
122+
TrackedEntityAttribute attributeValue = null;
123+
EventValue eventValue = null;
124+
String valueUid = requestParams.getValue();
125+
if (valueUid != null) {
126+
if (isProgramStageDataElement(valueUid)) {
127+
eventValue = resolveEventValue(valueUid, programs);
128+
} else {
129+
attributeValue = resolveAttributeValue(valueUid, programs, trackedEntityType);
130+
}
131+
}
115132

116133
return TrackedEntityQueryParams.builder()
117134
.trackedEntityType(trackedEntityType)
118-
.value(value)
119-
.aggregationType(aggregationTypeFor(requestParams.getAggregationType(), value))
135+
.attributeValue(attributeValue)
136+
.eventValue(eventValue)
137+
.aggregationType(
138+
aggregationTypeFor(
139+
requestParams.getAggregationType(), attributeValue != null || eventValue != null))
120140
.build();
121141
}
122142

143+
/**
144+
* A {@code value} referencing a program-stage data element is qualified with a program and a
145+
* program stage ({@code programUid.programStageUid.dataElementUid}); a bare UID references a
146+
* tracked entity attribute.
147+
*/
148+
private static boolean isProgramStageDataElement(String valueUid) {
149+
return valueUid.indexOf(DimensionIdentifierHelper.DIMENSION_SEPARATOR) >= 0;
150+
}
151+
123152
/**
124153
* Validates the requested aggregation type: it must be one this endpoint supports, and a
125154
* non-COUNT type requires a {@code value} to aggregate over.
@@ -144,26 +173,20 @@ private void validateAggregationType(CommonRequestParams requestParams) {
144173
}
145174

146175
/**
147-
* Resolves the requested value UID into a numeric attribute to aggregate over. The candidates are
148-
* the tracked entity type's own attributes and the attributes of the requested programs — the
149-
* same set the analytics_te table flattens into columns, so both resolve to a bare {@code
150-
* t_1."<uid>"} column. Data elements and program indicators are not accepted.
176+
* Resolves a bare value UID into a numeric attribute to aggregate over. The candidates are the
177+
* tracked entity type's own attributes and the attributes of the requested programs — the same
178+
* set the analytics_te table flattens into columns, so both resolve to a bare {@code t_1."<uid>"}
179+
* column. Data elements and program indicators are not accepted here.
151180
*
152-
* @param requestParams the {@link CommonRequestParams} carrying the value UID and programs.
181+
* @param valueUid the requested value UID.
182+
* @param programs the requested programs.
153183
* @param trackedEntityType the {@link TrackedEntityType}.
154-
* @return the resolved {@link TrackedEntityAttribute}, or null when no value was requested.
184+
* @return the resolved {@link TrackedEntityAttribute}.
155185
* @throws IllegalQueryException if the UID does not refer to a numeric attribute of the tracked
156186
* entity type or its programs.
157187
*/
158-
private TrackedEntityAttribute resolveValue(
159-
CommonRequestParams requestParams, TrackedEntityType trackedEntityType) {
160-
String valueUid = requestParams.getValue();
161-
if (valueUid == null) {
162-
return null;
163-
}
164-
165-
List<Program> programs = List.copyOf(programService.getPrograms(requestParams.getProgram()));
166-
188+
private TrackedEntityAttribute resolveAttributeValue(
189+
String valueUid, List<Program> programs, TrackedEntityType trackedEntityType) {
167190
return Stream.concat(
168191
getTrackedEntityAttributes(trackedEntityType), getProgramAttributes(programs))
169192
.filter(attribute -> valueUid.equals(attribute.getUid()))
@@ -172,18 +195,69 @@ private TrackedEntityAttribute resolveValue(
172195
.orElseThrow(() -> new IllegalQueryException(E7256, valueUid, trackedEntityType.getUid()));
173196
}
174197

198+
/**
199+
* Resolves a {@code programUid.programStageUid.dataElementUid} value into the numeric
200+
* program-stage data element to aggregate over. The program must be one of the requested
201+
* programs, the stage must belong to that program, and the data element must belong to that stage
202+
* and be numeric. The stage segment may carry an offset ({@code [n]}) selecting which occurrence
203+
* to read; an offset on the program segment is not allowed.
204+
*
205+
* @param valueUid the fully qualified value in {@code programUid.programStageUid.dataElementUid}
206+
* form.
207+
* @param programs the requested programs.
208+
* @return the resolved {@link EventValue}.
209+
* @throws IllegalQueryException if the value does not reference a numeric data element of a
210+
* program stage of the requested programs.
211+
*/
212+
private EventValue resolveEventValue(String valueUid, List<Program> programs) {
213+
StringDimensionIdentifier parsed;
214+
try {
215+
parsed = DimensionIdentifierHelper.fromFullDimensionId(valueUid);
216+
} catch (IllegalArgumentException e) {
217+
throw new IllegalQueryException(E7257, valueUid);
218+
}
219+
220+
if (!parsed.getProgram().isPresent()
221+
|| !parsed.getProgramStage().isPresent()
222+
|| parsed.getProgram().hasOffset()) {
223+
throw new IllegalQueryException(E7257, valueUid);
224+
}
225+
226+
String programUid = parsed.getProgram().getElement().getUid();
227+
String programStageUid = parsed.getProgramStage().getElement().getUid();
228+
String dataElementUid = parsed.getDimension().getUid();
229+
int offset = parsed.getProgramStage().getOffsetWithDefault();
230+
231+
ProgramStage programStage =
232+
programs.stream()
233+
.filter(program -> programUid.equals(program.getUid()))
234+
.map(Program::getProgramStages)
235+
.flatMap(Collection::stream)
236+
.filter(stage -> programStageUid.equals(stage.getUid()))
237+
.findFirst()
238+
.orElseThrow(() -> new IllegalQueryException(E7257, valueUid));
239+
240+
DataElement dataElement =
241+
programStage.getDataElements().stream()
242+
.filter(de -> dataElementUid.equals(de.getUid()))
243+
.filter(de -> de.getValueType().isNumeric())
244+
.findFirst()
245+
.orElseThrow(() -> new IllegalQueryException(E7257, valueUid));
246+
247+
return new EventValue(programStage, dataElement, offset);
248+
}
249+
175250
/**
176251
* Returns the aggregation function to apply: the requested one, falling back to AVERAGE when a
177-
* value attribute is present — matching the event/enrollment aggregate contract. Without a value
178-
* attribute the aggregate query counts TEIs, so no function is materialized.
252+
* value is present — matching the event/enrollment aggregate contract. Without a value the
253+
* aggregate query counts TEIs, so no function is materialized.
179254
*/
180-
private AggregationType aggregationTypeFor(
181-
AggregationType requested, TrackedEntityAttribute value) {
255+
private AggregationType aggregationTypeFor(AggregationType requested, boolean hasValue) {
182256
if (requested != null) {
183257
return requested;
184258
}
185259

186-
return value != null ? AggregationType.AVERAGE : null;
260+
return hasValue ? AggregationType.AVERAGE : null;
187261
}
188262

189263
private Set<String> getProgramUidsFromTrackedEntityType(TrackedEntityType trackedEntityType) {

0 commit comments

Comments
 (0)