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
@@ -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<String, String> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -61,18 +62,47 @@ public DimensionIdentifier<StringUid> fromString(

StringDimensionIdentifier parsed = fromFullDimensionId(fullDimensionId);

DimensionIdentifier<StringUid> 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<StringUid> canonicalizeKeyword(
DimensionIdentifier<StringUid> 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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -114,13 +115,19 @@ private void checkHeaders(Set<GridHeader> gridHeaders, Set<String> paramHeaders)
* programUid.stageUid.dimension <-> stageUid.dimension.
*/
private Optional<GridHeader> findMatchingHeader(List<GridHeader> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<JsonEnrollment> enrollments = parseEnrollmentsFromJson(super.getString("enrollments"));

DimensionIdentifier<DimensionParam> dimensionIdentifier = dimIdByKey.get(columnLabel);
DimensionIdentifier<DimensionParam> dimensionIdentifier = dimIdByKey.get(canonicalLabel);
if (dimensionIdentifier == null) {
throw new IllegalQueryException(E7250, columnLabel);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Program> programs = List.of(program);

// When
DimensionIdentifier<StringUid> 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<Program> programs = List.of(program);

// When
DimensionIdentifier<StringUid> 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<Program> 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
Expand Down
Loading
Loading