Skip to content

Commit 0acf81d

Browse files
fix: support stage-scoped data element headers in both short/full formats (TE) (#22918)
1 parent 86776f5 commit 0acf81d

File tree

7 files changed

+425
-25
lines changed

7 files changed

+425
-25
lines changed

dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverter.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,18 @@ private DimensionIdentifier<StringUid> handleTwoPartFormat(
126126
return handleProgramScoped(allowedPrograms, programOpt.get(), firstElement, dimension);
127127
}
128128

129-
// If first element is a stage UID with event-level dimension, handle as stage-scoped
129+
// Event-level static dimensions always follow stage-scoped handling.
130130
if (isEventLevelStaticDimension(dimension.getUid())) {
131131
return handleStageScoped(allowedPrograms, firstElement, dimension);
132132
}
133133

134-
// If first element is a stage UID with non-event-level static dimension, throw specific error
135-
if (isProgramStageUid(allowedPrograms, firstUid)
136-
&& isNonEventLevelStaticDimension(dimension.getUid())) {
137-
throw unsupportedStageDimensionError(dimension.getUid(), firstUid);
134+
// If first element is a stage UID, infer the program from the stage.
135+
// Non-event-level static dimensions remain unsupported for stage-specific scoping.
136+
if (isProgramStageUid(allowedPrograms, firstUid)) {
137+
if (isNonEventLevelStaticDimension(dimension.getUid())) {
138+
throw unsupportedStageDimensionError(dimension.getUid(), firstUid);
139+
}
140+
return handleStageScoped(allowedPrograms, firstElement, dimension);
138141
}
139142

140143
// Otherwise, program doesn't exist

dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/HeaderParamsHandler.java

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
import java.util.ArrayList;
3737
import java.util.List;
38+
import java.util.Optional;
3839
import java.util.Set;
3940
import org.hisp.dhis.analytics.common.CommonRequestParams;
4041
import org.hisp.dhis.analytics.common.ContextParams;
@@ -44,6 +45,7 @@
4445
import org.hisp.dhis.common.Grid;
4546
import org.hisp.dhis.common.GridHeader;
4647
import org.hisp.dhis.common.IllegalQueryException;
48+
import org.hisp.dhis.common.RepeatableStageParams;
4749
import org.hisp.dhis.feedback.ErrorMessage;
4850
import org.springframework.stereotype.Component;
4951

@@ -80,14 +82,10 @@ public void handle(
8082

8183
// Adds only the headers present in params, in the same order.
8284
paramHeaders.forEach(
83-
header -> {
84-
GridHeader gridHeader = new GridHeader(header);
85-
86-
if (gridHeaders.contains(gridHeader)) {
87-
int element = gridHeaders.indexOf(gridHeader);
88-
grid.addHeader(gridHeaders.get(element));
89-
}
90-
});
85+
header ->
86+
findMatchingHeader(gridHeaders, header)
87+
.map(matched -> withRequestedNameIfNeeded(matched, header))
88+
.ifPresent(grid::addHeader));
9189
}
9290

9391
checkHeaders(headers, paramHeaders);
@@ -102,13 +100,64 @@ public void handle(
102100
* "gridHeaders".
103101
*/
104102
private void checkHeaders(Set<GridHeader> gridHeaders, Set<String> paramHeaders) {
103+
List<GridHeader> requestedGridHeaders = new ArrayList<>(gridHeaders);
105104
paramHeaders.forEach(
106105
header -> {
107-
GridHeader gridHeader = new GridHeader(header);
108-
109-
if (!gridHeaders.contains(gridHeader)) {
106+
if (findMatchingHeader(requestedGridHeaders, header).isEmpty()) {
110107
throw new IllegalQueryException(new ErrorMessage(E7230, header));
111108
}
112109
});
113110
}
111+
112+
/**
113+
* Finds a matching header by exact name or supported short/full stage-scoped alias:
114+
* programUid.stageUid.dimension <-> stageUid.dimension.
115+
*/
116+
private Optional<GridHeader> findMatchingHeader(List<GridHeader> gridHeaders, String header) {
117+
GridHeader requested = new GridHeader(header);
118+
119+
if (gridHeaders.contains(requested)) {
120+
return Optional.of(gridHeaders.get(gridHeaders.indexOf(requested)));
121+
}
122+
123+
return gridHeaders.stream().filter(h -> isStageScopedAlias(h.getName(), header)).findFirst();
124+
}
125+
126+
private boolean isStageScopedAlias(String existingHeaderName, String requestedHeaderName) {
127+
long existingDots = existingHeaderName.chars().filter(c -> c == '.').count();
128+
long requestedDots = requestedHeaderName.chars().filter(c -> c == '.').count();
129+
130+
if (existingDots == 2 && requestedDots == 1) {
131+
return existingHeaderName.endsWith("." + requestedHeaderName);
132+
}
133+
134+
if (existingDots == 1 && requestedDots == 2) {
135+
return requestedHeaderName.endsWith("." + existingHeaderName);
136+
}
137+
138+
return false;
139+
}
140+
141+
private GridHeader withRequestedNameIfNeeded(GridHeader gridHeader, String requestedHeaderName) {
142+
if (requestedHeaderName.equals(gridHeader.getName())) {
143+
return gridHeader;
144+
}
145+
146+
GridHeader renamed =
147+
new GridHeader(
148+
requestedHeaderName,
149+
gridHeader.getColumn(),
150+
gridHeader.getValueType(),
151+
gridHeader.isHidden(),
152+
gridHeader.isMeta(),
153+
gridHeader.getOptionSetObject(),
154+
gridHeader.getLegendSetObject());
155+
156+
if (gridHeader.getStageOffset() != null) {
157+
renamed =
158+
renamed.withRepeatableStageParams(RepeatableStageParams.of(gridHeader.getStageOffset()));
159+
}
160+
161+
return renamed;
162+
}
114163
}

dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/query/jsonextractor/SqlRowSetJsonExtractorDelegator.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ public SqlRowSetJsonExtractorDelegator(
130130
+ DIMENSION_IDENTIFIER_SEP
131131
+ "ou";
132132
dimIdByKey.put(shortFormatKey, dimensionIdentifier);
133+
} else if (isEventLevelDataElementDimension(dimensionIdentifier)) {
134+
// For stage-scoped data elements, add short format alias
135+
// to support headers like programStageUid.dataElementUid.
136+
String shortFormatKey =
137+
dimensionIdentifier.getProgramStage().getElement().getUid()
138+
+ DIMENSION_IDENTIFIER_SEP
139+
+ dimensionIdentifier.getDimension().getUid();
140+
dimIdByKey.put(shortFormatKey, dimensionIdentifier);
133141
}
134142
}
135143
}
@@ -151,6 +159,11 @@ private static boolean isEventLevelOuDimensionalObject(
151159
== DimensionParamObjectType.ORGANISATION_UNIT;
152160
}
153161

162+
private static boolean isEventLevelDataElementDimension(
163+
DimensionIdentifier<DimensionParam> dimIdentifier) {
164+
return isDataElement(dimIdentifier);
165+
}
166+
154167
@Override
155168
@SneakyThrows
156169
public Object getObject(String columnLabel) throws InvalidResultSetAccessException {
@@ -162,6 +175,9 @@ public Object getObject(String columnLabel) throws InvalidResultSetAccessExcepti
162175
List<JsonEnrollment> enrollments = parseEnrollmentsFromJson(super.getString("enrollments"));
163176

164177
DimensionIdentifier<DimensionParam> dimensionIdentifier = dimIdByKey.get(columnLabel);
178+
if (dimensionIdentifier == null) {
179+
throw new IllegalQueryException(E7250, columnLabel);
180+
}
165181

166182
if (dimensionIdentifier.isEnrollmentDimension()) {
167183
return getObjectForEnrollments(enrollments, dimensionIdentifier);

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import static org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifierHelper.SUPPORTED_EVENT_STATIC_DIMENSIONS;
3535
import static org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifierHelper.getCustomLabelOrFullName;
3636
import static org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifierHelper.getCustomLabelOrHeaderColumnName;
37+
import static org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifierHelper.isDataElement;
3738
import static org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifierHelper.isEventLevelStaticDimension;
3839
import static org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifierHelper.joinedWithPrefixesIfNeeded;
3940
import static org.hisp.dhis.analytics.trackedentity.query.context.QueryContextConstants.TRACKED_ENTITY_ALIAS;
@@ -251,6 +252,26 @@ private static void addHeaderToMap(
251252

252253
headersMap.put(shortFormatName, shortFormatHeader);
253254
headersMap.put(offsetHeader.getName(), shortFormatHeader);
255+
} else if (isEventLevelDataElementDimension(dimIdentifier)) {
256+
// Stage-scoped data elements should also be addressable using short format
257+
// (programStageUid.dataElementUid) for consistency with stage-specific static headers.
258+
String shortFormatName =
259+
dimIdentifier.getProgramStage().getElement().getUid()
260+
+ DIMENSION_IDENTIFIER_SEP
261+
+ dimIdentifier.getDimension().getUid();
262+
263+
GridHeader shortFormatHeader =
264+
new GridHeader(
265+
shortFormatName,
266+
offsetHeader.getColumn(),
267+
offsetHeader.getValueType(),
268+
offsetHeader.isHidden(),
269+
offsetHeader.isMeta(),
270+
offsetHeader.getOptionSetObject(),
271+
offsetHeader.getLegendSetObject());
272+
273+
headersMap.put(shortFormatName, shortFormatHeader);
274+
headersMap.put(offsetHeader.getName(), offsetHeader);
254275
} else {
255276
headersMap.put(offsetHeader.getName(), offsetHeader);
256277
}
@@ -269,6 +290,11 @@ private static boolean isEventLevelOuDimensionalObject(
269290
== DimensionParamObjectType.ORGANISATION_UNIT;
270291
}
271292

293+
private static boolean isEventLevelDataElementDimension(
294+
DimensionIdentifier<DimensionParam> dimIdentifier) {
295+
return isDataElement(dimIdentifier);
296+
}
297+
272298
/**
273299
* Adds the stage offset to the given {@link GridHeader} if necessary.
274300
*

dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverterTest.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,6 @@ void fromStringStageScopedWithOffset() {
671671
@Test
672672
void fromStringWithStageUidAndNonStaticDimension() {
673673
// Given - stage UID (not a program UID) with a non-static dimension (e.g., data element)
674-
// This should fail because the first element is not a valid program
675674
Program program = new Program("prg-1");
676675
program.setUid("IpHINAT79UW");
677676
ProgramStage programStage = new ProgramStage("ps-1", program);
@@ -681,16 +680,23 @@ void fromStringWithStageUidAndNonStaticDimension() {
681680
List<Program> programs = List.of(program);
682681
String fullDimensionId = "ZkbAXlQUYJG.someDataElement";
683682

684-
// When - stage UID with non-static, non-event-level dimension
685-
IllegalArgumentException thrown =
686-
assertThrows(
687-
IllegalArgumentException.class, () -> converter.fromString(programs, fullDimensionId));
683+
// When
684+
DimensionIdentifier<StringUid> dimensionIdentifier =
685+
converter.fromString(programs, fullDimensionId);
688686

689-
// Then - should fail with "program does not exist" since it's not a recognized pattern
687+
// Then - should resolve program from stage and keep stage scope
690688
assertEquals(
691-
"Specified program ZkbAXlQUYJG does not exist",
692-
thrown.getMessage(),
693-
"Should fail as program not found for non-static dimensions");
689+
"someDataElement",
690+
dimensionIdentifier.getDimension().getUid(),
691+
"Dimension uid should be someDataElement");
692+
assertEquals(
693+
"IpHINAT79UW",
694+
dimensionIdentifier.getProgram().getElement().getUid(),
695+
"Program uid should be resolved from program stage");
696+
assertEquals(
697+
"ZkbAXlQUYJG",
698+
dimensionIdentifier.getProgramStage().getElement().getUid(),
699+
"Stage uid should be ZkbAXlQUYJG");
694700
}
695701

696702
@Test

dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/common/processing/HeaderParamsHandlerTest.java

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,21 @@
4141
import org.hisp.dhis.analytics.common.CommonRequestParams;
4242
import org.hisp.dhis.analytics.common.ContextParams;
4343
import org.hisp.dhis.analytics.common.params.CommonParsedParams;
44+
import org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifier;
45+
import org.hisp.dhis.analytics.common.params.dimension.DimensionParam;
46+
import org.hisp.dhis.analytics.common.params.dimension.DimensionParamType;
47+
import org.hisp.dhis.analytics.common.params.dimension.ElementWithOffset;
4448
import org.hisp.dhis.analytics.common.query.Field;
4549
import org.hisp.dhis.analytics.trackedentity.TrackedEntityQueryParams;
4650
import org.hisp.dhis.analytics.trackedentity.TrackedEntityRequestParams;
4751
import org.hisp.dhis.common.Grid;
52+
import org.hisp.dhis.common.IdScheme;
4853
import org.hisp.dhis.common.IllegalQueryException;
54+
import org.hisp.dhis.common.QueryItem;
55+
import org.hisp.dhis.common.ValueType;
56+
import org.hisp.dhis.dataelement.DataElement;
57+
import org.hisp.dhis.program.Program;
58+
import org.hisp.dhis.program.ProgramStage;
4959
import org.hisp.dhis.system.grid.ListGrid;
5060
import org.junit.jupiter.api.BeforeEach;
5161
import org.junit.jupiter.api.Test;
@@ -156,6 +166,91 @@ void testHandleWithNonExistingParamHeader() {
156166
assertTrue(ex.getMessage().contains("Header param `non-existing` does not exist"));
157167
}
158168

169+
@Test
170+
void testHandleWithStageScopedDataElementShortHeader() {
171+
// Given
172+
String stageUid = "A03MvHHogjR";
173+
String dataElementUid = "bx6fsa0t90x";
174+
String shortHeader = stageUid + "." + dataElementUid;
175+
Program program = new Program("program");
176+
program.setUid("IpHINAT79UW");
177+
ProgramStage stage = new ProgramStage("stage", program);
178+
stage.setUid(stageUid);
179+
DataElement dataElement = new DataElement("data element");
180+
dataElement.setUid(dataElementUid);
181+
dataElement.setValueType(ValueType.TEXT);
182+
QueryItem queryItem = new QueryItem(dataElement, null, ValueType.TEXT, null, null);
183+
DimensionIdentifier<DimensionParam> dataElementDimension =
184+
DimensionIdentifier.of(
185+
ElementWithOffset.of(program),
186+
ElementWithOffset.of(stage),
187+
DimensionParam.ofObject(
188+
queryItem, DimensionParamType.DIMENSIONS, IdScheme.UID, List.of()));
189+
190+
CommonRequestParams requestParams = new CommonRequestParams();
191+
requestParams.setHeaders(new LinkedHashSet<>(List.of(shortHeader)));
192+
ContextParams<TrackedEntityRequestParams, TrackedEntityQueryParams> contextParams =
193+
ContextParams.<TrackedEntityRequestParams, TrackedEntityQueryParams>builder()
194+
.commonRaw(requestParams)
195+
.commonParsed(
196+
CommonParsedParams.builder()
197+
.dimensionIdentifiers(List.of(dataElementDimension))
198+
.build())
199+
.build();
200+
List<Field> fields = List.of(ofUnquoted("ev", of("anyName"), dataElementDimension.getKey()));
201+
Grid grid = new ListGrid();
202+
203+
// When
204+
headerParamsHandler.handle(grid, contextParams, fields);
205+
206+
// Then
207+
assertEquals(1, grid.getHeaders().size());
208+
assertEquals(shortHeader, grid.getHeaders().get(0).getName());
209+
}
210+
211+
@Test
212+
void testHandleWithStageScopedDataElementFullHeader() {
213+
// Given
214+
String stageUid = "A03MvHHogjR";
215+
String programUid = "IpHINAT79UW";
216+
String dataElementUid = "bx6fsa0t90x";
217+
String fullHeader = programUid + "." + stageUid + "." + dataElementUid;
218+
Program program = new Program("program");
219+
program.setUid(programUid);
220+
ProgramStage stage = new ProgramStage("stage", program);
221+
stage.setUid(stageUid);
222+
DataElement dataElement = new DataElement("data element");
223+
dataElement.setUid(dataElementUid);
224+
dataElement.setValueType(ValueType.TEXT);
225+
QueryItem queryItem = new QueryItem(dataElement, null, ValueType.TEXT, null, null);
226+
DimensionIdentifier<DimensionParam> dataElementDimension =
227+
DimensionIdentifier.of(
228+
ElementWithOffset.of(program),
229+
ElementWithOffset.of(stage),
230+
DimensionParam.ofObject(
231+
queryItem, DimensionParamType.DIMENSIONS, IdScheme.UID, List.of()));
232+
233+
CommonRequestParams requestParams = new CommonRequestParams();
234+
requestParams.setHeaders(new LinkedHashSet<>(List.of(fullHeader)));
235+
ContextParams<TrackedEntityRequestParams, TrackedEntityQueryParams> contextParams =
236+
ContextParams.<TrackedEntityRequestParams, TrackedEntityQueryParams>builder()
237+
.commonRaw(requestParams)
238+
.commonParsed(
239+
CommonParsedParams.builder()
240+
.dimensionIdentifiers(List.of(dataElementDimension))
241+
.build())
242+
.build();
243+
List<Field> fields = List.of(ofUnquoted("ev", of("anyName"), dataElementDimension.getKey()));
244+
Grid grid = new ListGrid();
245+
246+
// When
247+
headerParamsHandler.handle(grid, contextParams, fields);
248+
249+
// Then
250+
assertEquals(1, grid.getHeaders().size());
251+
assertEquals(fullHeader, grid.getHeaders().get(0).getName());
252+
}
253+
159254
private CommonRequestParams stubCommonParamsWithHeaders() {
160255
Set<String> headers = new LinkedHashSet<>(List.of("oucode", "ouname", "lastupdated"));
161256

0 commit comments

Comments
 (0)