diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/params/dimension/DimensionAliases.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/params/dimension/DimensionAliases.java new file mode 100644 index 000000000000..61761a1813e3 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/params/dimension/DimensionAliases.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2004-2022, 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.common.params.dimension; + +import java.util.Map; + +/** + * Exact, case-sensitive keyword aliases mapping a keyword to the canonical dimension id it stands + * for. Used at the tracked entity level so that {@code programId.ENROLLMENT_OU} behaves as {@code + * programId.ou} and {@code programId.enrollmentouname} as {@code programId.ouname}, mirroring the + * event pipeline. Only the literal spelling/case is aliased; any other casing is left untouched and + * rejected downstream. + */ +public final class DimensionAliases { + private DimensionAliases() {} + + private static final Map ALIASES = + Map.of("ENROLLMENT_OU", "ou", "enrollmentouname", "ouname"); + + /** Returns the canonical dimension id for an exact keyword alias, or the input unchanged. */ + public static String canonicalize(String dimensionId) { + if (dimensionId == null) { + return null; + } + return ALIASES.getOrDefault(dimensionId, dimensionId); + } + + /** + * Canonicalizes the dimension segment (the part after the last {@code .}) of a possibly + * program/stage-prefixed header string, leaving the prefix untouched. For example {@code + * programId.enrollmentouname} becomes {@code programId.ouname}. + */ + public static String canonicalizeHeader(String header) { + if (header == null) { + return null; + } + + int lastDot = header.lastIndexOf('.'); + + if (lastDot < 0) { + return canonicalize(header); + } + + return header.substring(0, lastDot + 1) + canonicalize(header.substring(lastDot + 1)); + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverter.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverter.java index d0716a3016e7..35936c2ea512 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverter.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverter.java @@ -34,6 +34,7 @@ import java.util.List; import java.util.Optional; +import org.hisp.dhis.analytics.common.params.dimension.DimensionAliases; import org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifier; import org.hisp.dhis.analytics.common.params.dimension.DimensionParam.StaticDimension; import org.hisp.dhis.analytics.common.params.dimension.ElementWithOffset; @@ -61,18 +62,47 @@ public DimensionIdentifier fromString( StringDimensionIdentifier parsed = fromFullDimensionId(fullDimensionId); + DimensionIdentifier dimensionIdentifier; + // Case 1: dimension only (e.g., "jklm") if (!parsed.getProgram().isPresent()) { - return handleDimensionOnly(parsed); + dimensionIdentifier = handleDimensionOnly(parsed); } - // Case 2: fully scoped (e.g., "programUid.stageUid.dimensionId") - if (parsed.getProgramStage().isPresent()) { - return handleFullyScoped(allowedPrograms, parsed); + else if (parsed.getProgramStage().isPresent()) { + dimensionIdentifier = handleFullyScoped(allowedPrograms, parsed); } - // Case 3 & 4: two-part format (e.g., "xxx.dimension") - return handleTwoPartFormat(allowedPrograms, parsed); + else { + dimensionIdentifier = handleTwoPartFormat(allowedPrograms, parsed); + } + + return canonicalizeKeyword(dimensionIdentifier); + } + + /** + * Replaces an exact keyword alias (e.g. {@code ENROLLMENT_OU}) with its canonical dimension id + * (e.g. {@code ou}) so that every downstream consumer (dimension resolution, headers, metadata) + * sees the canonical dimension. Non-alias dimensions are returned unchanged. + */ + private DimensionIdentifier canonicalizeKeyword( + DimensionIdentifier dimensionIdentifier) { + StringUid dimension = dimensionIdentifier.getDimension(); + + if (dimension == null) { + return dimensionIdentifier; + } + + String canonical = DimensionAliases.canonicalize(dimension.getUid()); + + if (canonical.equals(dimension.getUid())) { + return dimensionIdentifier; + } + + return DimensionIdentifier.of( + dimensionIdentifier.getProgram(), + dimensionIdentifier.getProgramStage(), + StringUid.of(canonical)); } /** Handles dimension-only case where no program or stage is specified. */ diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/HeaderParamsHandler.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/HeaderParamsHandler.java index 5f9d2bb649d6..09a769e0b66c 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/HeaderParamsHandler.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/processing/HeaderParamsHandler.java @@ -39,6 +39,7 @@ import java.util.Set; import org.hisp.dhis.analytics.common.CommonRequestParams; import org.hisp.dhis.analytics.common.ContextParams; +import org.hisp.dhis.analytics.common.params.dimension.DimensionAliases; import org.hisp.dhis.analytics.common.query.Field; import org.hisp.dhis.analytics.trackedentity.TrackedEntityQueryParams; import org.hisp.dhis.analytics.trackedentity.TrackedEntityRequestParams; @@ -114,13 +115,19 @@ private void checkHeaders(Set gridHeaders, Set paramHeaders) * programUid.stageUid.dimension <-> stageUid.dimension. */ private Optional findMatchingHeader(List gridHeaders, String header) { - GridHeader requested = new GridHeader(header); + // Match on the canonical form of keyword aliases (e.g. programId.enrollmentouname -> + // programId.ouname) while leaving the originally requested spelling to flow through to the + // returned header name via withRequestedNameIfNeeded. + String canonicalHeader = DimensionAliases.canonicalizeHeader(header); + GridHeader requested = new GridHeader(canonicalHeader); if (gridHeaders.contains(requested)) { return Optional.of(gridHeaders.get(gridHeaders.indexOf(requested))); } - return gridHeaders.stream().filter(h -> isStageScopedAlias(h.getName(), header)).findFirst(); + return gridHeaders.stream() + .filter(h -> isStageScopedAlias(h.getName(), canonicalHeader)) + .findFirst(); } private boolean isStageScopedAlias(String existingHeaderName, String requestedHeaderName) { diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/query/jsonextractor/SqlRowSetJsonExtractorDelegator.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/query/jsonextractor/SqlRowSetJsonExtractorDelegator.java index 06ad472b7ec5..c63e98d46b0b 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/query/jsonextractor/SqlRowSetJsonExtractorDelegator.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/query/jsonextractor/SqlRowSetJsonExtractorDelegator.java @@ -56,6 +56,7 @@ import lombok.SneakyThrows; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.Strings; +import org.hisp.dhis.analytics.common.params.dimension.DimensionAliases; import org.hisp.dhis.analytics.common.params.dimension.DimensionIdentifier; import org.hisp.dhis.analytics.common.params.dimension.DimensionParam; import org.hisp.dhis.analytics.common.params.dimension.DimensionParamObjectType; @@ -167,14 +168,20 @@ private static boolean isEventLevelDataElementDimension( @Override @SneakyThrows public Object getObject(String columnLabel) throws InvalidResultSetAccessException { + // Keyword aliases (e.g. programId.enrollmentouname) are kept verbatim in the header name, but + // both the rowset columns and dimIdByKey are keyed by the canonical dimension + // (programId.ouname). Resolving against the canonical form makes the alias extract exactly the + // same value as its canonical dimension. + String canonicalLabel = DimensionAliases.canonicalizeHeader(columnLabel); + // if the column is present in the rowset, we invoke the default behavior - if (existingColumnsInRowSet.contains(columnLabel)) { - return super.getObject(columnLabel); + if (existingColumnsInRowSet.contains(canonicalLabel)) { + return super.getObject(canonicalLabel); } // if the column is not present in the rowset, we check if it is present in the json string List enrollments = parseEnrollmentsFromJson(super.getString("enrollments")); - DimensionIdentifier dimensionIdentifier = dimIdByKey.get(columnLabel); + DimensionIdentifier dimensionIdentifier = dimIdByKey.get(canonicalLabel); if (dimensionIdentifier == null) { throw new IllegalQueryException(E7250, columnLabel); } diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverterTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverterTest.java index 3a02b6180d19..368573085b3f 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverterTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/common/processing/DimensionIdentifierConverterTest.java @@ -551,6 +551,88 @@ void fromStringWithProgramLevelOuDimensionShouldNotBeTreatedAsEventLevel() { "Stage should be empty for program-level OU dimensions"); } + @Test + void fromStringCanonicalizesProgramScopedEnrollmentOuKeywordToOu() { + // Given - the ENROLLMENT_OU keyword with a program UID is an alias for programId.ou. + // It must be canonicalized to "ou" and stay program-scoped (not stage-scoped, no error). + Program program = new Program("prg-1"); + program.setUid("IpHINAT79UW"); + ProgramStage programStage = new ProgramStage("ps-1", program); + programStage.setUid("ZkbAXlQUYJG"); + program.setProgramStages(Set.of(programStage)); + + List programs = List.of(program); + + // When + DimensionIdentifier dimensionIdentifier = + converter.fromString(programs, "IpHINAT79UW.ENROLLMENT_OU"); + + // Then + assertEquals( + "ou", + dimensionIdentifier.getDimension().getUid(), + "ENROLLMENT_OU keyword should be canonicalized to ou"); + assertEquals( + "IpHINAT79UW", + dimensionIdentifier.getProgram().getElement().getUid(), + "Program uid should be IpHINAT79UW"); + assertEquals( + emptyElementWithOffset(), + dimensionIdentifier.getProgramStage(), + "Stage should be empty for the program-scoped enrollment OU keyword"); + } + + @Test + void fromStringCanonicalizesProgramScopedEnrollmentOuNameKeywordToOuName() { + // Given - the enrollmentouname keyword with a program UID is an alias for programId.ouname. + Program program = new Program("prg-1"); + program.setUid("IpHINAT79UW"); + ProgramStage programStage = new ProgramStage("ps-1", program); + programStage.setUid("ZkbAXlQUYJG"); + program.setProgramStages(Set.of(programStage)); + + List programs = List.of(program); + + // When + DimensionIdentifier dimensionIdentifier = + converter.fromString(programs, "IpHINAT79UW.enrollmentouname"); + + // Then + assertEquals( + "ouname", + dimensionIdentifier.getDimension().getUid(), + "enrollmentouname keyword should be canonicalized to ouname"); + assertEquals( + "IpHINAT79UW", + dimensionIdentifier.getProgram().getElement().getUid(), + "Program uid should be IpHINAT79UW"); + assertEquals( + emptyElementWithOffset(), + dimensionIdentifier.getProgramStage(), + "Stage should be empty for the program-scoped enrollment OU name keyword"); + } + + @Test + void fromStringDoesNotCanonicalizeVariantSpellingsOfTheKeywords() { + // Given - only the exact keywords are aliased. Variant casings/spellings are left untouched, + // so they are rejected as unknown dimensions downstream. + Program program = new Program("prg-1"); + program.setUid("IpHINAT79UW"); + + List programs = List.of(program); + + // When / Then - the raw (non-canonicalized) uid is preserved + assertEquals( + "enrollment_ou", + converter.fromString(programs, "IpHINAT79UW.enrollment_ou").getDimension().getUid()); + assertEquals( + "enrollmentou", + converter.fromString(programs, "IpHINAT79UW.enrollmentou").getDimension().getUid()); + assertEquals( + "ENROLLMENTOUNAME", + converter.fromString(programs, "IpHINAT79UW.ENROLLMENTOUNAME").getDimension().getUid()); + } + @Test void fromStringWithStageUidAndEnrollmentDateDimensionShouldFail() { // Given - program stage UID that is ALSO a program UID, used with enrollment-level dimension diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/trackedentity/TrackedEntityQueryTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/trackedentity/TrackedEntityQueryTest.java index bf28f55e855e..1cbd56a122a4 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/trackedentity/TrackedEntityQueryTest.java +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/trackedentity/TrackedEntityQueryTest.java @@ -58,7 +58,7 @@ * @author maikel arabori */ public class TrackedEntityQueryTest extends AnalyticsApiTest { - private AnalyticsTrackedEntityActions analyticsTrackedEntityActions = + private final AnalyticsTrackedEntityActions analyticsTrackedEntityActions = new AnalyticsTrackedEntityActions(); private QueryParamsBuilder withDefaultHeaders(QueryParamsBuilder queryParamsBuilder) { @@ -1454,6 +1454,158 @@ public void queryWithProgramAndFilterByEnrollmentOrgUnit() { "BV4IomHvri4")); } + @Test + public void queryWithProgramAndFilterByEnrollmentOuKeyword() { + // ENROLLMENT_OU is an exact-match alias for the program-scoped `ou` dimension, so this must + // return exactly the same result as queryWithProgramAndFilterByEnrollmentOrgUnit (which uses + // dimension=IpHINAT79UW.ou:BV4IomHvri4). + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("program=IpHINAT79UW") + .add("lastUpdated=LAST_10_YEARS") + .add("dimension=IpHINAT79UW.ENROLLMENT_OU:BV4IomHvri4") + .add("desc=lastupdated") + .add("relativePeriodDate=2022-09-27"); + + // When + ApiResponse response = + analyticsTrackedEntityActions.query().get("nEenWmSyUEp", JSON, JSON, params); + + // Then + response + .validate() + .statusCode(200) + .body("headers", hasSize(equalTo(16))) + .body("rows", hasSize(equalTo(14))) + .body("metaData.dimensions.ou", hasSize(equalTo(1))) + .body("metaData.dimensions.ou", hasItem("BV4IomHvri4")) + .body("height", equalTo(14)) + .body("width", equalTo(16)) + .body("headerWidth", equalTo(16)); + + // Validate the first three rows, as samples (identical to the `ou` variant). + validateRow( + response, + 0, + List.of( + "NYKMYcUHzSt", + "2015-08-07 15:47:24.377", + ", ()", + "2015-08-07 15:47:24.376", + ", ()", + "", + "", + "", + "Ahmadiyya Muslim Hospital", + "OU_268246", + "Sierra Leone / Tonkolili / Yoni / Ahmadiyya Muslim Hospital", + "Female", + "", + "Angela", + "Wright", + "BV4IomHvri4")); + + validateRow( + response, + 1, + List.of( + "sM7XmpfgKFb", + "2015-08-07 15:47:24.033", + ", ()", + "2015-08-07 15:47:24.032", + ", ()", + "", + "", + "", + "Ahmadiyya Muslim Hospital", + "OU_268246", + "Sierra Leone / Tonkolili / Yoni / Ahmadiyya Muslim Hospital", + "Female", + "", + "Brenda", + "Morgan", + "BV4IomHvri4")); + + validateRow( + response, + 2, + List.of( + "vFSQneulDLz", + "2015-08-07 15:47:22.383", + ", ()", + "2015-08-07 15:47:22.383", + ", ()", + "", + "", + "", + "Ahmadiyya Muslim Hospital", + "OU_268246", + "Sierra Leone / Tonkolili / Yoni / Ahmadiyya Muslim Hospital", + "Male", + "", + "Edward", + "Murray", + "BV4IomHvri4")); + } + + @Test + public void queryWithProgramHeaderEnrollmentOuNameKeywordPreservesRequestedName() { + // enrollmentouname is an exact-match alias for the program-scoped `ouname` dimension. The data + // returned must be identical to headers=IpHINAT79UW.ouname, but the requested keyword spelling + // must be preserved in the response header name (IpHINAT79UW.enrollmentouname), mirroring how + // stage-scoped header aliases keep their requested spelling. + QueryParamsBuilder keywordParams = + new QueryParamsBuilder() + .add("program=IpHINAT79UW") + .add("lastUpdated=LAST_10_YEARS") + .add("headers=IpHINAT79UW.enrollmentouname,lZGmxYbs97q") + .add("desc=lastupdated") + .add("relativePeriodDate=2022-09-27"); + + QueryParamsBuilder canonicalParams = + new QueryParamsBuilder() + .add("program=IpHINAT79UW") + .add("lastUpdated=LAST_10_YEARS") + .add("headers=IpHINAT79UW.ouname,lZGmxYbs97q") + .add("desc=lastupdated") + .add("relativePeriodDate=2022-09-27"); + + // When + ApiResponse keyword = + analyticsTrackedEntityActions.query().get("nEenWmSyUEp", JSON, JSON, keywordParams); + ApiResponse canonical = + analyticsTrackedEntityActions.query().get("nEenWmSyUEp", JSON, JSON, canonicalParams); + + // Then + keyword.validate().statusCode(200); + canonical.validate().statusCode(200); + + // The requested keyword spelling is preserved in the response header name. + validateHeader( + keyword, + 0, + "IpHINAT79UW.enrollmentouname", + "Organisation Unit Name, Child Programme", + "TEXT", + "java.lang.String", + false, + true); + + // The canonical request keeps the canonical header name. + validateHeader( + canonical, + 0, + "IpHINAT79UW.ouname", + "Organisation Unit Name, Child Programme", + "TEXT", + "java.lang.String", + false, + true); + + // The data is identical regardless of which alias was used. + keyword.validate().body("rows", equalTo(canonical.extractList("rows"))); + } + @Test public void queryWithProgramAndFilterByEventOrgUnit() { // Given