diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/eventvisualization/SimpleDimension.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/eventvisualization/SimpleDimension.java index c412e96c66e4..8b53f85e00b8 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/eventvisualization/SimpleDimension.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/eventvisualization/SimpleDimension.java @@ -75,7 +75,8 @@ public enum Type { CREATED_BY("createdBy", DATA_X), LAST_UPDATED_BY("lastUpdatedBy", DATA_X), OU("ou", ORGANISATION_UNIT), - ENROLLMENT_OU("enrollmentOu", ORGANISATION_UNIT); + ENROLLMENT_OU("enrollmentOu", ORGANISATION_UNIT), + REGISTRATION_OU("registrationOu", ORGANISATION_UNIT); private final String dimension; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/feedback/ErrorCode.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/feedback/ErrorCode.java index 239655f790fd..5eeb8d2a30ea 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/feedback/ErrorCode.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/feedback/ErrorCode.java @@ -622,6 +622,11 @@ public enum ErrorCode { 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`"), + E7258("Dimension `{0}` is not supported for a program without registration"), + E7259("Dimension `{0}` must specify organisation units in an aggregate query"), + E7260( + "Dimension `{0}` does not support organisation units at different hierarchy levels in an aggregate query"), + E7261("Sorting by `{0}` requires the `{1}` dimension"), /* Org unit analytics */ E7300(Constants.AT_LEAST_ONE_ORGANISATION_UNIT_MUST_BE_SPECIFIED), diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/EventQueryParams.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/EventQueryParams.java index 99330fdaeafc..ed6094c43b60 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/EventQueryParams.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/EventQueryParams.java @@ -289,6 +289,21 @@ public record GeometrySource(String coordinateField, String source) {} /** Whether ENROLLMENT_OU dimension was requested via relative keywords (e.g. USER_ORGUNIT). */ private boolean enrollmentOuDimensionHierarchical = false; + /** + * Org units when REGISTRATION_OU is used as a dimension, already expanded from any keyword form + * and therefore carrying their hierarchy level. + */ + private List registrationOuDimensionItems = new ArrayList<>(); + + /** Org units when REGISTRATION_OU is used as a filter. */ + private List registrationOuFilterItems = new ArrayList<>(); + + /** + * Whether REGISTRATION_OU was named as a dimension, independently of whether it carries items. A + * dimension without items projects the output columns without restricting any rows. + */ + private boolean registrationOuDimensionRequested = false; + // ------------------------------------------------------------------------- // Constructors // ------------------------------------------------------------------------- @@ -370,6 +385,9 @@ protected EventQueryParams instance() { params.enrollmentOuDimensionLevels = new LinkedHashSet<>(this.enrollmentOuDimensionLevels); params.enrollmentOuFilterLevels = new LinkedHashSet<>(this.enrollmentOuFilterLevels); params.enrollmentOuDimensionHierarchical = this.enrollmentOuDimensionHierarchical; + params.registrationOuDimensionItems = new ArrayList<>(this.registrationOuDimensionItems); + params.registrationOuFilterItems = new ArrayList<>(this.registrationOuFilterItems); + params.registrationOuDimensionRequested = this.registrationOuDimensionRequested; return params; } @@ -1297,6 +1315,51 @@ public boolean hasEnrollmentOuDimension() { return isNotEmpty(enrollmentOuDimensionItems) || !enrollmentOuDimensionLevels.isEmpty(); } + /** Returns true if REGISTRATION_OU was named as a dimension, with or without items. */ + public boolean hasRegistrationOuDimension() { + return registrationOuDimensionRequested; + } + + public boolean hasRegistrationOuFilter() { + return isNotEmpty(registrationOuFilterItems); + } + + /** Returns true if REGISTRATION_OU was named at all, as a dimension or as a filter. */ + public boolean hasRegistrationOu() { + return hasRegistrationOuDimension() || hasRegistrationOuFilter(); + } + + /** + * Returns true if the REGISTRATION_OU dimension carries org units, which is the condition for the + * aggregate disaggregation column to exist. A dimension named without org units projects the + * query output columns but has nothing to group by. + */ + public boolean hasRegistrationOuAggregateColumn() { + return isNotEmpty(registrationOuDimensionItems); + } + + /** + * Returns true if REGISTRATION_OU restricts the query, i.e. carries org units as a dimension or + * as a filter. A dimension named without items does not restrict anything and so does not count + * as an organisation unit condition. + */ + public boolean hasRegistrationOuRestriction() { + return isNotEmpty(registrationOuDimensionItems) || isNotEmpty(registrationOuFilterItems); + } + + public List getRegistrationOuDimensionItems() { + return registrationOuDimensionItems; + } + + public List getRegistrationOuFilterItems() { + return registrationOuFilterItems; + } + + /** Returns the REGISTRATION_OU org units from both the dimension and the filter. */ + public List getAllRegistrationOuItems() { + return ListUtils.union(registrationOuDimensionItems, registrationOuFilterItems); + } + public boolean hasEnrollmentOuFilter() { return isNotEmpty(enrollmentOuFilterItems) || !enrollmentOuFilterLevels.isEmpty(); } @@ -1959,6 +2022,17 @@ public Builder withEnrollmentOuFilterLevels(Set levels) { return this; } + public Builder withRegistrationOuDimension(List items) { + this.params.registrationOuDimensionItems = new ArrayList<>(items); + this.params.registrationOuDimensionRequested = true; + return this; + } + + public Builder withRegistrationOuFilter(List items) { + this.params.registrationOuFilterItems = new ArrayList<>(items); + return this; + } + public Builder withEnrollmentOuDimensionHierarchical(boolean hierarchical) { this.params.enrollmentOuDimensionHierarchical = hierarchical; return this; diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManager.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManager.java index da297fff932d..b61886c1429c 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManager.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManager.java @@ -142,6 +142,7 @@ import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagDataHandler; import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagInfoInitializer; import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagQueryGenerator; +import org.hisp.dhis.analytics.event.data.registrationou.RegistrationOuSqlCoordinator; import org.hisp.dhis.analytics.event.data.stage.StageQuerySqlFacade; import org.hisp.dhis.analytics.table.EnrollmentAnalyticsColumnName; import org.hisp.dhis.analytics.table.EventAnalyticsColumnName; @@ -599,6 +600,10 @@ protected void addDimensionSelectColumns( OrgUnitSqlCoordinator.addDimensionSelectColumns( columns, params, isGroupByClause, isAggregated, getAnalyticsType(), sqlBuilder); + RegistrationOuSqlCoordinator.dimensionSelectColumn( + params, isGroupByClause, isAggregated, sqlBuilder) + .ifPresent(columns::add); + if (params.hasEnrollmentStatuses() && params.isEnrollmentAggregateQuery()) { columns.add(ColumnAndAlias.ofColumn(ENROLLMENT_STATUS_COLUMN_NAME).asSql()); } diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AggregatedRowBuilder.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AggregatedRowBuilder.java index 2852f0ff2d88..be67484108e9 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AggregatedRowBuilder.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AggregatedRowBuilder.java @@ -40,6 +40,7 @@ import java.util.Locale; import java.util.function.BiFunction; import java.util.function.Function; +import org.hisp.dhis.analytics.common.ColumnHeader; import org.hisp.dhis.analytics.event.EventQueryParams; import org.hisp.dhis.analytics.event.data.ou.OrgUnitRowAccess; import org.hisp.dhis.common.DimensionalObject; @@ -206,6 +207,11 @@ private void addDimensionData() { row.add(extractStringValue(OrgUnitRowAccess.enrollmentOuResultColumn(), ValueType.TEXT)); } + // A REGISTRATION_OU dimension without items emits no aggregate column, so nothing to read. + if (params.hasRegistrationOuAggregateColumn()) { + row.add(extractStringValue(ColumnHeader.REGISTRATION_OU.getItem(), ValueType.TEXT)); + } + if (params.hasEnrollmentStatuses() && params.isAggregatedEvents()) { row.add(rowSet.getString(ENROLLMENT_STATUS_COLUMN_NAME)); } diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/DefaultEventDataQueryService.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/DefaultEventDataQueryService.java index d606963d8e60..1eb2198c40d3 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/DefaultEventDataQueryService.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/DefaultEventDataQueryService.java @@ -77,6 +77,7 @@ import org.hisp.dhis.analytics.event.QueryItemLocator; import org.hisp.dhis.analytics.event.data.ou.OrgUnitSqlConstants; import org.hisp.dhis.analytics.event.data.queryitem.QueryItemFilterHandlerRegistry; +import org.hisp.dhis.analytics.event.data.registrationou.RegistrationOuSqlConstants; import org.hisp.dhis.analytics.table.EnrollmentAnalyticsColumnName; import org.hisp.dhis.analytics.table.EventAnalyticsColumnName; import org.hisp.dhis.common.BaseDimensionalItemObject; @@ -123,6 +124,7 @@ public class DefaultEventDataQueryService implements EventDataQueryService { private static final String SCHEDULED_DATE_DIMENSION = "SCHEDULED_DATE"; private static final String ENROLLMENT_OU_DIMENSION = "ENROLLMENT_OU"; + private static final String REGISTRATION_OU_DIMENSION = RegistrationOuSqlConstants.DIMENSION_NAME; private static final String LEVEL_PREFIX = "LEVEL-"; private final ProgramService programService; @@ -718,6 +720,12 @@ private void addFiltersToParams( resolveEnrollmentOuFilter(params, request, userOrgUnits, input.items(), idScheme); continue; } + if (REGISTRATION_OU_DIMENSION.equals(input.dimensionId())) { + requireRegistrationProgram(pr); + params.withRegistrationOuFilter( + resolveRegistrationOuItems(input.items(), request, userOrgUnits, idScheme)); + continue; + } if (isProgramStatusDimension(input.dimensionId())) { if (isAggregateRequest(request)) { requireNonEmptyStatusFilter(input.items(), input.rawDimension()); @@ -768,6 +776,11 @@ private void addDimensionsToParams( resolveEnrollmentOuDimension(params, request, userOrgUnits, input.items(), idScheme); continue; } + if (REGISTRATION_OU_DIMENSION.equals(input.dimensionId())) { + resolveRegistrationOuDimension( + params, request, userOrgUnits, input.items(), idScheme, pr); + continue; + } if (isProgramStatusDimension(input.dimensionId())) { if (isAggregateRequest(request)) { params.addDimension(getProgramStatusDimension(input.items(), input.rawDimension())); @@ -863,7 +876,8 @@ private void processDimension( String dimensionId = getDimensionFromParam(rawDimension); List items = getDimensionItemsFromParam(rawDimension); - if (ENROLLMENT_OU_DIMENSION.equals(dimensionId)) { + if (ENROLLMENT_OU_DIMENSION.equals(dimensionId) + || REGISTRATION_OU_DIMENSION.equals(dimensionId)) { normalizedInputs.add( new NormalizedDimensionInput(rawDimension, dimensionId, items, groupUUID)); return; @@ -903,9 +917,34 @@ private List normalizeDimensionInputs( } peTracker.insertMergedInto(normalizedInputs); + rejectRepeatedOrgUnitDimensions(normalizedInputs); return normalizedInputs; } + /** + * Rejects a request naming ENROLLMENT_OU or REGISTRATION_OU more than once. Both are held outside + * {@code params.dimensions} and so are invisible to {@link + * org.hisp.dhis.analytics.DataQueryParams#getDuplicateDimensions()}, which is what raises E7201 + * for an ordinary dimension. Without this check the last occurrence processed would silently win, + * and which one that is follows hash order rather than the order the caller wrote them in. + * + *

Several org units in one dimension ({@code REGISTRATION_OU:uidA;uidB}) is a single + * occurrence and stays valid, as does the same dimension used once as a dimension and once as a + * filter, since those are normalized separately. + */ + private void rejectRepeatedOrgUnitDimensions(List normalizedInputs) { + for (String dimensionId : List.of(ENROLLMENT_OU_DIMENSION, REGISTRATION_OU_DIMENSION)) { + long occurrences = + normalizedInputs.stream() + .filter(input -> dimensionId.equals(input.dimensionId())) + .count(); + + if (occurrences > 1) { + throwIllegalQueryEx(ErrorCode.E7201, dimensionId); + } + } + } + private record NormalizedDimensionInput( String rawDimension, String dimensionId, List items, UUID groupUUID) {} @@ -1207,6 +1246,57 @@ private EnrollmentOuResolution resolveEnrollmentOuItems( return new EnrollmentOuResolution(uidItems, levels); } + /** + * Resolves REGISTRATION_OU items through the standard "ou" dimension pipeline, so that every + * keyword form (USER_ORGUNIT and its variants, LEVEL-n, OU_GROUP-uid) expands to concrete org + * units already carrying their hierarchy level. + */ + private List resolveRegistrationOuItems( + List items, + EventDataQueryRequest request, + List userOrgUnits, + IdScheme idScheme) { + if (items == null || items.isEmpty()) { + return List.of(); + } + + DimensionalObject ouDimension = + dataQueryService.getDimension("ou", items, request, userOrgUnits, true, idScheme); + + if (ouDimension == null) { + return List.of(); + } + + return ouDimension.getItems().stream() + .filter(OrganisationUnit.class::isInstance) + .map(OrganisationUnit.class::cast) + .toList(); + } + + private void resolveRegistrationOuDimension( + EventQueryParams.Builder params, + EventDataQueryRequest request, + List userOrgUnits, + List items, + IdScheme idScheme, + Program program) { + requireRegistrationProgram(program); + + if ((items == null || items.isEmpty()) && isAggregateRequest(request)) { + throwIllegalQueryEx(ErrorCode.E7259, REGISTRATION_OU_DIMENSION); + } + + params.withRegistrationOuDimension( + resolveRegistrationOuItems(items, request, userOrgUnits, idScheme)); + } + + /** Registration org unit is a property of a tracked entity, so it needs a tracker program. */ + private void requireRegistrationProgram(Program program) { + if (program != null && !program.isRegistration()) { + throwIllegalQueryEx(ErrorCode.E7258, REGISTRATION_OU_DIMENSION); + } + } + private record EnrollmentOuResolution( List uidItems, Set levels) {} @@ -1229,6 +1319,10 @@ enum SortableItems { ColumnHeader.SCHEDULED_DATE.getItem(), EventAnalyticsColumnName.SCHEDULED_DATE_COLUMN_NAME), ORG_UNIT_NAME(ColumnHeader.ORG_UNIT_NAME.getItem()), ORG_UNIT_NAME_HIERARCHY(ColumnHeader.ORG_UNIT_NAME_HIERARCHY.getItem()), + // Projected under their own aliases only when REGISTRATION_OU is a dimension, so the query + // validator rejects sorting on them otherwise. + REGISTRATION_OU(ColumnHeader.REGISTRATION_OU.getItem()), + REGISTRATION_OU_NAME(ColumnHeader.REGISTRATION_OU_NAME.getItem()), ORG_UNIT_CODE(ColumnHeader.ORG_UNIT_CODE.getItem()), PROGRAM_STATUS( ColumnHeader.PROGRAM_STATUS.getItem(), diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/DefaultEventQueryValidator.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/DefaultEventQueryValidator.java index 24a470e4e11e..70325df5a0a5 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/DefaultEventQueryValidator.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/DefaultEventQueryValidator.java @@ -44,8 +44,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.Strings; +import org.hisp.dhis.analytics.common.ColumnHeader; import org.hisp.dhis.analytics.event.EventQueryParams; import org.hisp.dhis.analytics.event.EventQueryValidator; +import org.hisp.dhis.analytics.event.data.registrationou.RegistrationOuSqlConstants; import org.hisp.dhis.analytics.table.EnrollmentAnalyticsColumnName; import org.hisp.dhis.analytics.table.EventAnalyticsColumnName; import org.hisp.dhis.common.DimensionType; @@ -56,6 +58,7 @@ import org.hisp.dhis.common.QueryOperator; import org.hisp.dhis.common.RequestTypeAware; import org.hisp.dhis.common.ValueType; +import org.hisp.dhis.commons.collection.ListUtils; import org.hisp.dhis.feedback.ErrorCode; import org.hisp.dhis.feedback.ErrorMessage; import org.hisp.dhis.program.Program; @@ -69,6 +72,11 @@ @RequiredArgsConstructor public class DefaultEventQueryValidator implements EventQueryValidator { + private static final String REGISTRATION_OU_DIMENSION = RegistrationOuSqlConstants.DIMENSION_NAME; + + private static final Set REGISTRATION_OU_SORT_ITEMS = + Set.of(ColumnHeader.REGISTRATION_OU.getItem(), ColumnHeader.REGISTRATION_OU_NAME.getItem()); + private final SystemSettingsProvider settingsProvider; // ------------------------------------------------------------------------- @@ -95,7 +103,9 @@ public ErrorMessage validateForErrorMessage(EventQueryParams params) { if (params == null) { throw new IllegalQueryException(ErrorCode.E7100); } - if (!params.hasOrganisationUnits() && !params.hasEnrollmentOu()) { + if (!params.hasOrganisationUnits() + && !params.hasEnrollmentOu() + && !params.hasRegistrationOuRestriction()) { return new ErrorMessage(ErrorCode.E7200); } Optional enrollmentOuSortColumn = params.getEnrollmentOuSortColumn(); @@ -105,6 +115,10 @@ public ErrorMessage validateForErrorMessage(EventQueryParams params) { if (!params.getDuplicateDimensions().isEmpty()) { return new ErrorMessage(ErrorCode.E7201, params.getDuplicateDimensions()); } + ErrorMessage registrationOuSortError = validateRegistrationOuSorting(params); + if (registrationOuSortError != null) { + return registrationOuSortError; + } // Check for duplicate stage dimension identifiers (must be before E7202 check) // This applies to both EVENT and ENROLLMENT queries @@ -209,6 +223,27 @@ public ErrorMessage validateForErrorMessage(EventQueryParams params) { return null; } + /** + * Rejects sorting by a registration organisation unit column when REGISTRATION_OU is not a + * dimension. Both columns are projected only for the dimension, so {@code registrationouname} + * would order by an alias that is not in the select list, and {@code registrationou} would fall + * back to the raw analytics column and order by the organisation unit the tracked entity was + * registered in rather than the requested ancestor. A dimension named without organisation units + * still projects both columns, so it is enough. + */ + private ErrorMessage validateRegistrationOuSorting(EventQueryParams params) { + if (!params.isSorting() || params.hasRegistrationOuDimension()) { + return null; + } + + return ListUtils.union(params.getAsc(), params.getDesc()).stream() + .map(QueryItem::getItemId) + .filter(REGISTRATION_OU_SORT_ITEMS::contains) + .findFirst() + .map(item -> new ErrorMessage(ErrorCode.E7261, item, REGISTRATION_OU_DIMENSION)) + .orElse(null); + } + private boolean hasDateQueryItem(EventQueryParams params) { return params.getItemsAndItemFilters().stream() .anyMatch( diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EventAggregateService.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EventAggregateService.java index 83d71f678682..b39ca8bd166f 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EventAggregateService.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EventAggregateService.java @@ -351,6 +351,16 @@ private void addDimensionHeaders(EventQueryParams params, Grid grid) { new GridHeader(ColumnHeader.ENROLLMENT_OU.getItem(), ouLabel, TEXT, false, true)); } + if (params.hasRegistrationOuAggregateColumn()) { + grid.addHeader( + new GridHeader( + ColumnHeader.REGISTRATION_OU.getItem(), + ColumnHeader.REGISTRATION_OU.getName(), + TEXT, + false, + true)); + } + if (params.hasEnrollmentStatuses()) { grid.addHeader( new GridHeader(PROGRAM_STATUS.getItem(), PROGRAM_STATUS.getName(), TEXT, false, true)); diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEnrollmentAnalyticsManager.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEnrollmentAnalyticsManager.java index f66e6ed917b9..e427af361bbb 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEnrollmentAnalyticsManager.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEnrollmentAnalyticsManager.java @@ -65,6 +65,7 @@ import org.apache.commons.lang3.StringUtils; import org.hisp.dhis.analytics.TimeField; import org.hisp.dhis.analytics.analyze.ExecutionPlanStore; +import org.hisp.dhis.analytics.common.ColumnHeader; import org.hisp.dhis.analytics.common.CteContext; import org.hisp.dhis.analytics.common.CteDefinition; import org.hisp.dhis.analytics.common.EndpointItem; @@ -74,6 +75,7 @@ import org.hisp.dhis.analytics.event.data.aggregate.AggregatedEnrollmentDateHeaderResolver; import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagInfoInitializer; import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagQueryGenerator; +import org.hisp.dhis.analytics.event.data.registrationou.RegistrationOuSqlCoordinator; import org.hisp.dhis.analytics.event.data.stage.StageHeaderClassifier; import org.hisp.dhis.analytics.event.data.stage.StageQuerySqlFacade; import org.hisp.dhis.analytics.table.AbstractJdbcTableManager; @@ -385,6 +387,8 @@ protected String getFromClause(EventQueryParams params) { resolveDateFieldPeriodBucketJoins(params, ANALYTICS_TBL_ALIAS) .forEach(join -> sql.append(join.toSql()).append(" ")); + sql.append(RegistrationOuSqlCoordinator.joinClause(params, sqlBuilder)); + return sql.append(joinOrgUnitTables(params, getAnalyticsType())).toString(); } @@ -392,6 +396,7 @@ protected String getFromClause(EventQueryParams params) { @Override void addFromClause(SelectBuilder sb, EventQueryParams params) { sb.from(params.getTableName(), "ax"); + RegistrationOuSqlCoordinator.addJoinIfNeeded(sb, params, sqlBuilder); } /** @@ -557,6 +562,8 @@ protected String getWhereClause(EventQueryParams params) { + ",4326) "; } + sql += RegistrationOuSqlCoordinator.wherePredicate(params, hlp, sqlBuilder); + return sql; } @@ -734,11 +741,21 @@ private void addBaseAggregationCte( addDimensionSelectColumns(columns, params, true, true); removeLegacyPeriodDimensionColumns(columns, params); + // The registration OU projection is added separately, qualified and aliased, because stripping + // its table alias would leave a uidlevelN reference that is ambiguous once regous is joined. + boolean joinsRegistrationOu = params.hasRegistrationOu(); + columns.removeIf(RegistrationOuSqlCoordinator::isRegistrationOuColumn); + SelectBuilder sb = new SelectBuilder(); sb.addColumn(ENROLLMENT_COL, "ax", ENROLLMENT_COL); for (String column : Sets.newHashSet(columns)) { - sb.addColumn(SqlColumnParser.removeTableAlias(column)); + String stripped = SqlColumnParser.removeTableAlias(column); + sb.addColumn( + joinsRegistrationOu + ? RegistrationOuSqlCoordinator.preserveQualifierIfAmbiguous(column, stripped) + : stripped); } + RegistrationOuSqlCoordinator.baseCteSelectColumn(params, sqlBuilder).ifPresent(sb::addColumn); addNonDefaultPeriodSourceColumns(sb, params); @@ -755,6 +772,11 @@ private void addBaseAggregationCte( } String colToAdd = dateHeaderResolver.normalizeHeaderKey(SqlColumnParser.removeTableAlias(column)); + // Added above as a qualified, aliased projection; a bare copy would resolve to the raw + // registration OU uid instead of the requested ancestor level. + if (ColumnHeader.REGISTRATION_OU.getItem().equals(colToAdd)) { + continue; + } if (!programIndicators.contains(colToAdd)) { Optional headerProjection = @@ -1183,6 +1205,8 @@ void addSelectClause(SelectBuilder sb, EventQueryParams params, CteContext cteCo aggregatedAssembler.addAggregatedColumns(sb); } else { aggregatedAssembler.addStandardColumns(sb, cteContext, getStandardColumns(params)); + + RegistrationOuSqlCoordinator.querySelectColumns(params, sqlBuilder).forEach(sb::addColumn); } // Append columns from CTE definitions diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEventAnalyticsManager.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEventAnalyticsManager.java index 54e10e9d9a67..c7aefa2e7f8c 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEventAnalyticsManager.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/JdbcEventAnalyticsManager.java @@ -71,6 +71,7 @@ import org.hisp.dhis.analytics.event.data.ou.OrgUnitSqlCoordinator; import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagInfoInitializer; import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagQueryGenerator; +import org.hisp.dhis.analytics.event.data.registrationou.RegistrationOuSqlCoordinator; import org.hisp.dhis.analytics.event.data.stage.StageQuerySqlFacade; import org.hisp.dhis.analytics.table.AbstractJdbcTableManager; import org.hisp.dhis.analytics.table.EventAnalyticsColumnName; @@ -365,6 +366,7 @@ protected AnalyticsType getAnalyticsType() { void addFromClause(SelectBuilder sb, EventQueryParams params) { sb.from(params.getTableName(), ANALYTICS_TBL_ALIAS); OrgUnitSqlCoordinator.addJoinIfNeeded(sb, params, sqlBuilder); + RegistrationOuSqlCoordinator.addJoinIfNeeded(sb, params, sqlBuilder); } /** @@ -501,6 +503,7 @@ protected String getFromClause(EventQueryParams params) { .forEach(join -> sql.append(join.toSql()).append(" ")); OrgUnitSqlCoordinator.appendLegacyJoin(sql, params, sqlBuilder); + sql.append(RegistrationOuSqlCoordinator.joinClause(params, sqlBuilder)); return sql.append(joinOrgUnitTables(params, getAnalyticsType())).toString(); } @@ -738,6 +741,8 @@ protected String getWhereClause(EventQueryParams params) { OrgUnitSqlCoordinator.appendWherePredicateIfNeeded(enrollmentOuSql, hlp, params, sqlBuilder); sql += enrollmentOuSql; + sql += RegistrationOuSqlCoordinator.wherePredicate(params, hlp, sqlBuilder); + if (params.hasBbox()) { sql += hlp.whereAnd() @@ -918,6 +923,7 @@ void addSelectClause(SelectBuilder sb, EventQueryParams params, CteContext cteCo List columns = new ArrayList<>(getStandardColumns(params)); addDimensionSelectColumns(columns, params, false, false); OrgUnitSqlCoordinator.addQuerySelectColumns(columns, params, sqlBuilder); + columns.addAll(RegistrationOuSqlCoordinator.querySelectColumns(params, sqlBuilder)); columns.addAll(eventItemSelectColumnResolver.resolve(params, cteContext)); columns.forEach( diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlConstants.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlConstants.java new file mode 100644 index 000000000000..ecada485d333 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlConstants.java @@ -0,0 +1,56 @@ +/* + * 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.event.data.registrationou; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.hisp.dhis.resourcetable.table.OrganisationUnitStructureResourceTable; + +/** Shared identifiers for REGISTRATION_OU query and aggregate handling. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RegistrationOuSqlConstants { + + /** The dimension keyword as written in a request. */ + public static final String DIMENSION_NAME = "REGISTRATION_OU"; + + public static final String STRUCT_TABLE = OrganisationUnitStructureResourceTable.TABLE_NAME; + + /** + * Distinct from the {@code ous} alias used for the default org unit join, so both can appear in + * one query. + */ + public static final String STRUCT_ALIAS = "regous"; + + public static final String STRUCT_UID_COLUMN = "organisationunituid"; + + public static final String STRUCT_NAME_COLUMN = "name"; + + public static final String UID_LEVEL_PREFIX = "uidlevel"; +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlCoordinator.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlCoordinator.java new file mode 100644 index 000000000000..6623d2514c1a --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlCoordinator.java @@ -0,0 +1,247 @@ +/* + * 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.event.data.registrationou; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; +import static org.hisp.dhis.analytics.util.AnalyticsUtils.throwIllegalQueryEx; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.analytics.util.sql.SelectBuilder; +import org.hisp.dhis.commons.util.SqlHelper; +import org.hisp.dhis.db.sql.SqlBuilder; +import org.hisp.dhis.feedback.ErrorCode; +import org.hisp.dhis.organisationunit.OrganisationUnit; + +/** + * Orchestrates REGISTRATION_OU SQL clauses for query and aggregate paths. Applies to both event and + * enrollment analytics, since the registration org unit column exists in both table types. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RegistrationOuSqlCoordinator { + + /** + * Adds the org unit structure join to a {@link SelectBuilder} query when registration org unit is + * used as a dimension or a filter. + * + * @param sb builder being assembled + * @param params query parameters + * @param sqlBuilder database-specific SQL builder for column quoting + */ + public static void addJoinIfNeeded( + SelectBuilder sb, EventQueryParams params, SqlBuilder sqlBuilder) { + if (!params.hasRegistrationOu()) { + return; + } + + sb.innerJoin( + RegistrationOuSqlConstants.STRUCT_TABLE, + RegistrationOuSqlConstants.STRUCT_ALIAS, + alias -> RegistrationOuSqlFragments.joinCondition(alias, sqlBuilder)); + } + + /** + * Returns the org unit structure join clause, or an empty string when registration org unit is + * not used. + * + * @param params query parameters + * @param sqlBuilder database-specific SQL builder for column quoting + * @return {@code inner join ... on ...} clause, or an empty string + */ + public static String joinClause(EventQueryParams params, SqlBuilder sqlBuilder) { + return params.hasRegistrationOu() ? RegistrationOuSqlFragments.innerJoinClause(sqlBuilder) : ""; + } + + /** + * Appends the registration org unit where conditions. Items are grouped by hierarchy level and + * matched against the corresponding {@code uidlevel} column, which yields "at or below" + * semantics. Within a dimension or filter the per-level predicates are OR-ed, because the + * requested subtrees form a union. The dimension and the filter are AND-ed, because each + * restricts the result independently. + * + * @param params query parameters + * @param hlp helper used to add {@code where/and} prefixes + * @param sqlBuilder database-specific SQL builder for column quoting + * @return where conditions, or an empty string when registration org unit restricts nothing + */ + public static String wherePredicate( + EventQueryParams params, SqlHelper hlp, SqlBuilder sqlBuilder) { + List restrictions = new ArrayList<>(); + + addRestriction(restrictions, params.getRegistrationOuDimensionItems(), sqlBuilder); + addRestriction(restrictions, params.getRegistrationOuFilterItems(), sqlBuilder); + + if (restrictions.isEmpty()) { + return ""; + } + + return hlp.whereAnd() + " " + String.join(" and ", restrictions) + " "; + } + + /** + * Adds the REGISTRATION_OU select and group-by column for aggregate queries, producing one output + * row per requested org unit, each aggregating its whole subtree. + * + * @param params query parameters + * @param isGroupBy whether the column is destined for the group-by clause + * @param isAggregated whether the query is in aggregated mode + * @param sqlBuilder database-specific SQL builder for column quoting + * @return the column, or empty when the query has no registration org unit disaggregation + */ + public static Optional dimensionSelectColumn( + EventQueryParams params, boolean isGroupBy, boolean isAggregated, SqlBuilder sqlBuilder) { + if (!isAggregated || !params.hasRegistrationOuAggregateColumn()) { + return Optional.empty(); + } + + return Optional.of( + RegistrationOuSqlFragments.selectUidLevel( + singleLevelOf(params.getRegistrationOuDimensionItems()), isGroupBy, sqlBuilder)); + } + + /** + * Adds the REGISTRATION_OU query output columns, being the UID and the name of the org unit the + * tracked entity was registered in. + * + * @param params query parameters + * @param sqlBuilder database-specific SQL builder for column quoting + * @return the UID and name projections, or an empty list when the dimension is absent + */ + public static List querySelectColumns(EventQueryParams params, SqlBuilder sqlBuilder) { + if (!params.hasRegistrationOuDimension()) { + return List.of(); + } + + return List.of( + RegistrationOuSqlFragments.selectRegistrationOuUid(sqlBuilder), + RegistrationOuSqlFragments.selectRegistrationOuName(sqlBuilder)); + } + + /** + * True if the given projection is the registration OU column contributed by {@link + * #addDimensionSelectColumns}. The enrollment aggregate base CTE strips table aliases from its + * projections, which would turn this column into a bare {@code uidlevelN} that is ambiguous + * between the analytics table and the joined org unit structure table, so it has to be recognised + * and handled separately. + */ + public static boolean isRegistrationOuColumn(String column) { + return column != null && column.contains(RegistrationOuSqlConstants.STRUCT_ALIAS + "."); + } + + /** + * Returns the registration OU projection for the enrollment aggregate base CTE, qualified and + * aliased as {@code registrationou} so the outer query can select and group by it off the CTE. + * + * @param params query parameters + * @param sqlBuilder database-specific SQL builder for column quoting + * @return the projection, or empty when the dimension carries no org units + */ + public static Optional baseCteSelectColumn( + EventQueryParams params, SqlBuilder sqlBuilder) { + if (!params.hasRegistrationOuAggregateColumn()) { + return Optional.empty(); + } + + return Optional.of( + RegistrationOuSqlFragments.selectUidLevel( + singleLevelOf(params.getRegistrationOuDimensionItems()), false, sqlBuilder)); + } + + /** + * Keeps the table qualifier on a {@code uidlevelN} projection instead of the alias-stripped form. + * The org unit structure table joined for registration org unit also carries {@code uidlevelN} + * columns, so a bare reference is ambiguous whenever the org unit dimension is itself + * level-based. The CTE's output column name is unaffected, because a column reference is named + * after the column rather than its qualifier. + * + * @param qualified the projection as produced by the dimension resolver, table alias included + * @param stripped the same projection with its table alias removed + * @return the qualified form for org unit level columns, otherwise the stripped form + */ + public static String preserveQualifierIfAmbiguous(String qualified, String stripped) { + String bare = stripped == null ? "" : stripped.replace("\"", "").trim(); + + return bare.startsWith(RegistrationOuSqlConstants.UID_LEVEL_PREFIX) ? qualified : stripped; + } + + // ------------------------------------------------------------------------- + // Supportive methods + // ------------------------------------------------------------------------- + + /** Adds one parenthesised restriction covering all levels present in the given items. */ + private static void addRestriction( + List restrictions, List items, SqlBuilder sqlBuilder) { + if (items.isEmpty()) { + return; + } + + String predicate = + byLevel(items).entrySet().stream() + .map( + entry -> + RegistrationOuSqlFragments.predicateByUidLevel( + entry.getKey(), quotedUids(entry.getValue()), sqlBuilder)) + .collect(joining(" or ")); + + restrictions.add("(" + predicate + ")"); + } + + /** + * Returns the single hierarchy level shared by the given org units. One group-by column cannot + * represent several levels at once, and an org unit below two requested ancestors at different + * levels belongs to both, so a mixed-level set has no unambiguous disaggregation. + */ + private static int singleLevelOf(List items) { + Map> byLevel = byLevel(items); + + if (byLevel.size() > 1) { + throwIllegalQueryEx(ErrorCode.E7260, RegistrationOuSqlConstants.DIMENSION_NAME); + } + + return byLevel.keySet().iterator().next(); + } + + /** Groups by level in ascending order, so generated predicates are deterministic. */ + private static Map> byLevel(List items) { + return items.stream().collect(groupingBy(OrganisationUnit::getLevel, TreeMap::new, toList())); + } + + private static String quotedUids(List items) { + return items.stream().map(item -> "'" + item.getUid() + "'").collect(joining(",")); + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlFragments.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlFragments.java new file mode 100644 index 000000000000..598097200fe5 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlFragments.java @@ -0,0 +1,139 @@ +/* + * 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.event.data.registrationou; + +import static org.hisp.dhis.analytics.AnalyticsConstants.ANALYTICS_TBL_ALIAS; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.hisp.dhis.analytics.common.ColumnHeader; +import org.hisp.dhis.analytics.table.EventAnalyticsColumnName; +import org.hisp.dhis.db.sql.SqlBuilder; + +/** Pure SQL fragments used by REGISTRATION_OU support. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RegistrationOuSqlFragments { + + /** + * Builds the join predicate between the analytics table and the org unit structure table. + * + * @param structAlias alias used for the org unit structure table in the current query + * @param sqlBuilder database-specific SQL builder for column quoting + * @return SQL join condition using quoted identifiers + */ + public static String joinCondition(String structAlias, SqlBuilder sqlBuilder) { + return sqlBuilder.quote(structAlias, RegistrationOuSqlConstants.STRUCT_UID_COLUMN) + + " = " + + sqlBuilder.quote( + ANALYTICS_TBL_ALIAS, EventAnalyticsColumnName.REGISTRATION_OU_COLUMN_NAME); + } + + /** + * Builds the string-based inner join clause for registration org unit resolution. The join is + * inner because the registration org unit column is never null. + * + * @param sqlBuilder database-specific SQL builder for column quoting + * @return full {@code inner join ... on ...} clause with trailing space + */ + public static String innerJoinClause(SqlBuilder sqlBuilder) { + return "inner join " + + RegistrationOuSqlConstants.STRUCT_TABLE + + " as " + + RegistrationOuSqlConstants.STRUCT_ALIAS + + " on " + + joinCondition(RegistrationOuSqlConstants.STRUCT_ALIAS, sqlBuilder) + + " "; + } + + /** + * Builds a predicate matching org units at or below the given org units, by comparing the + * ancestor UID held at their hierarchy level. + * + * @param level the org unit hierarchy level of the requested org units + * @param quotedUidList comma-delimited and quoted UID values + * @param sqlBuilder database-specific SQL builder for column quoting + * @return SQL predicate fragment + */ + public static String predicateByUidLevel(int level, String quotedUidList, SqlBuilder sqlBuilder) { + return sqlBuilder.quote( + RegistrationOuSqlConstants.STRUCT_ALIAS, + RegistrationOuSqlConstants.UID_LEVEL_PREFIX + level) + + " in (" + + quotedUidList + + ")"; + } + + /** + * Builds the aggregate disaggregation column, which is the ancestor UID at the level of the + * requested org units. This is what makes each requested org unit one output row aggregating its + * whole subtree. + * + * @param level the org unit hierarchy level of the requested org units + * @param groupBy when true returns a raw column reference for group-by, otherwise an aliased + * projection + * @param sqlBuilder database-specific SQL builder for column quoting + * @return SQL fragment + */ + public static String selectUidLevel(int level, boolean groupBy, SqlBuilder sqlBuilder) { + String column = + sqlBuilder.quote( + RegistrationOuSqlConstants.STRUCT_ALIAS, + RegistrationOuSqlConstants.UID_LEVEL_PREFIX + level); + + return groupBy ? column : column + " as " + ColumnHeader.REGISTRATION_OU.getItem(); + } + + /** + * Builds the registration org unit UID projection for query output. This is the org unit the + * tracked entity was registered in, not the requested ancestor. + * + * @param sqlBuilder database-specific SQL builder for column quoting + * @return SQL fragment + */ + public static String selectRegistrationOuUid(SqlBuilder sqlBuilder) { + return sqlBuilder.quote( + RegistrationOuSqlConstants.STRUCT_ALIAS, RegistrationOuSqlConstants.STRUCT_UID_COLUMN) + + " as " + + ColumnHeader.REGISTRATION_OU.getItem(); + } + + /** + * Builds the registration org unit display name projection for query output. + * + * @param sqlBuilder database-specific SQL builder for column quoting + * @return SQL fragment + */ + public static String selectRegistrationOuName(SqlBuilder sqlBuilder) { + return sqlBuilder.quote( + RegistrationOuSqlConstants.STRUCT_ALIAS, RegistrationOuSqlConstants.STRUCT_NAME_COLUMN) + + " as " + + ColumnHeader.REGISTRATION_OU_NAME.getItem(); + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/package-info.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/package-info.java new file mode 100644 index 000000000000..c94920167b7a --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/registrationou/package-info.java @@ -0,0 +1,5 @@ +/** + * SQL support for the REGISTRATION_OU dimension and filter, which select and group events and + * enrollments by the org unit where the tracked entity was registered. + */ +package org.hisp.dhis.analytics.event.data.registrationou; diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/security/DefaultAnalyticsSecurityManager.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/security/DefaultAnalyticsSecurityManager.java index bcffc4b93df5..73d18fd1b01a 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/security/DefaultAnalyticsSecurityManager.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/security/DefaultAnalyticsSecurityManager.java @@ -115,7 +115,16 @@ public void decideAccess( */ private void decideAccessDataViewOrganisationUnits(DataQueryParams params, UserDetails user) throws IllegalQueryException { - decideAccessDataViewOrganisationUnits(params.getAllTypedOrganisationUnits(), user); + List queryOrgUnits = new ArrayList<>(params.getAllTypedOrganisationUnits()); + + // Registration OU items are held outside the ou dimension and so are invisible to + // getAllTypedOrganisationUnits. Without adding them here they would escape the data view scope + // check entirely. + if (params instanceof EventQueryParams eventParams) { + queryOrgUnits.addAll(eventParams.getAllRegistrationOuItems()); + } + + decideAccessDataViewOrganisationUnits(queryOrgUnits, user); } /** diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/HeaderHelper.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/HeaderHelper.java index 5ccfb4203b66..1a29baf19980 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/HeaderHelper.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/HeaderHelper.java @@ -74,25 +74,15 @@ public static void addCommonHeaders( addDimensionHeaders(grid, periods, params.getProgram()); if (params.hasEnrollmentStatuses() && !grid.headerExists(PROGRAM_STATUS.getItem())) { - grid.addHeader( - new GridHeader(PROGRAM_STATUS.getItem(), PROGRAM_STATUS.getName(), TEXT, false, true)); + addMetaHeaders(grid, List.of(PROGRAM_STATUS)); } if (params.hasEnrollmentOuDimension()) { - grid.addHeader( - new GridHeader( - ColumnHeader.ENROLLMENT_OU.getItem(), - ColumnHeader.ENROLLMENT_OU.getName(), - TEXT, - false, - true)); - grid.addHeader( - new GridHeader( - ColumnHeader.ENROLLMENT_OU_NAME.getItem(), - ColumnHeader.ENROLLMENT_OU_NAME.getName(), - TEXT, - false, - true)); + addMetaHeaders(grid, List.of(ColumnHeader.ENROLLMENT_OU, ColumnHeader.ENROLLMENT_OU_NAME)); + } + + if (params.hasRegistrationOuDimension()) { + addMetaHeaders(grid, registrationOuHeaders(params)); } DisplayProperty displayProperty = params.getDisplayProperty(); @@ -100,36 +90,59 @@ public static void addCommonHeaders( for (QueryItem item : params.getItems()) { grid.addHeader(buildGridHeader(item, context)); + addStageOrgUnitHeaders(grid, item, params); + } + } - if (isStageOuDimension(item) && params.hasHeaders()) { - String stageUid = item.getProgramStage().getUid(); - - if (params.getHeaders().contains(stageUid + ".ouname")) { - grid.addHeader( - new GridHeader( - stageUid + ".ouname", - "Organisation unit name", - "Organisation unit name", - TEXT, - false, - true, - null, - null)); - } - - if (params.getHeaders().contains(stageUid + ".oucode")) { - grid.addHeader( - new GridHeader( - stageUid + ".oucode", - "Organisation unit code", - "Organisation unit code", - TEXT, - false, - true, - null, - null)); - } - } + /** Adds a metadata header per given column, all of which share the same shape. */ + private static void addMetaHeaders(Grid grid, List headers) { + for (ColumnHeader header : headers) { + grid.addHeader(new GridHeader(header.getItem(), header.getName(), TEXT, false, true)); + } + } + + /** + * The aggregate grid carries one column per dimension and its base CTE projects only the org unit + * uid, so the name column belongs to the query endpoints alone. + */ + private static List registrationOuHeaders(EventQueryParams params) { + return params.isAggregatedEnrollments() + ? List.of(ColumnHeader.REGISTRATION_OU) + : List.of(ColumnHeader.REGISTRATION_OU, ColumnHeader.REGISTRATION_OU_NAME); + } + + /** Adds the org unit name and code headers of a stage scoped org unit dimension, if requested. */ + private static void addStageOrgUnitHeaders(Grid grid, QueryItem item, EventQueryParams params) { + if (!isStageOuDimension(item) || !params.hasHeaders()) { + return; + } + + String stageUid = item.getProgramStage().getUid(); + + if (params.getHeaders().contains(stageUid + ".ouname")) { + grid.addHeader( + new GridHeader( + stageUid + ".ouname", + "Organisation unit name", + "Organisation unit name", + TEXT, + false, + true, + null, + null)); + } + + if (params.getHeaders().contains(stageUid + ".oucode")) { + grid.addHeader( + new GridHeader( + stageUid + ".oucode", + "Organisation unit code", + "Organisation unit code", + TEXT, + false, + true, + null, + null)); } } @@ -193,12 +206,7 @@ private static String getDateFieldLabel(String dateField, Program program) { } private static String toDateFieldDisplayName(String dateField) { - String[] parts = dateField.toLowerCase().split("_"); - if (parts.length == 0) { - return dateField; - } - parts[0] = parts[0].substring(0, 1).toUpperCase() + parts[0].substring(1); - return String.join(" ", parts); + return MetadataItemsHandler.toDateFieldDisplayName(dateField); } private static GridHeader buildGridHeader(QueryItem item, HeaderBuildContext context) { diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandler.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandler.java index 5d96ef255636..1e8540aa47ec 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandler.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandler.java @@ -41,6 +41,7 @@ import static org.hisp.dhis.analytics.AnalyticsMetaDataKey.ORG_UNIT_NAME_HIERARCHY; import static org.hisp.dhis.analytics.common.ColumnHeader.ENROLLMENT_OU; import static org.hisp.dhis.analytics.common.ColumnHeader.PROGRAM_STATUS; +import static org.hisp.dhis.analytics.common.ColumnHeader.REGISTRATION_OU; import static org.hisp.dhis.analytics.event.data.OrganisationUnitResolver.isStageOuDimension; import static org.hisp.dhis.analytics.event.data.QueryItemHelper.getItemOptions; import static org.hisp.dhis.analytics.event.data.QueryItemHelper.getItemOptionsAsFilter; @@ -256,6 +257,7 @@ private Map getMetadataItems( addPeriodDimensionValueMetadata(metadataItemMap, params, includeDetails); addDateFieldDimensionMetadata(metadataItemMap, params); addEnrollmentOuMetadata(metadataItemMap, params, includeDetails); + addRegistrationOuMetadata(metadataItemMap, params, includeDetails); addProgramStatusMetadata(metadataItemMap, params); return metadataItemMap; @@ -645,6 +647,25 @@ private String getEnrollmentOuDisplayName() { return "Enrollment org. unit"; } + private void addRegistrationOuMetadata( + Map metadataItemMap, EventQueryParams params, boolean includeDetails) { + List items = params.getRegistrationOuDimensionItems(); + + if (items.isEmpty()) { + return; + } + + metadataItemMap.putIfAbsent( + REGISTRATION_OU.getItem(), new MetadataItem(REGISTRATION_OU.getName())); + + for (OrganisationUnit item : items) { + metadataItemMap.put( + item.getUid(), + new MetadataItem( + item.getDisplayProperty(params.getDisplayProperty()), includeDetails ? item : null)); + } + } + private void addProgramStatusMetadata( Map metadataItemMap, EventQueryParams params) { if (params.hasEnrollmentStatuses()) { @@ -736,6 +757,7 @@ private Map> getDimensionItems( addQueryItemDimensions(dimensionItems, params, itemOptions); addItemFiltersToDimensionItems(params.getItemFilters(), dimensionItems); addEnrollmentOuDimensionItems(dimensionItems, params); + addRegistrationOuDimensionItems(dimensionItems, params); addProgramStatusDimensionItems(dimensionItems, params); return dimensionItems; @@ -819,6 +841,16 @@ private void addEnrollmentOuDimensionItems( } } + private void addRegistrationOuDimensionItems( + Map> dimensionItems, EventQueryParams params) { + List items = params.getRegistrationOuDimensionItems(); + + if (!items.isEmpty()) { + dimensionItems.put( + REGISTRATION_OU.getItem(), items.stream().map(OrganisationUnit::getUid).toList()); + } + } + private void addProgramStatusDimensionItems( Map> dimensionItems, EventQueryParams params) { if (params.hasEnrollmentStatuses()) { diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/EventQueryParamsTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/EventQueryParamsTest.java index 0c4e37f3207c..da129da4fabd 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/EventQueryParamsTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/EventQueryParamsTest.java @@ -178,6 +178,43 @@ void before() { new MonthlyPeriodType().createPeriod(new DateTime(2014, 6, 1, 0, 0).toDate())); } + @Test + void testRegistrationOuItemsSurviveCopyConstructor() { + EventQueryParams original = + new EventQueryParams.Builder() + .withRegistrationOuDimension(List.of(ouA)) + .withRegistrationOuFilter(List.of(ouB)) + .build(); + + EventQueryParams copy = new EventQueryParams.Builder(original).build(); + + assertTrue(copy.hasRegistrationOuDimension()); + assertTrue(copy.hasRegistrationOuFilter()); + assertTrue(copy.hasRegistrationOuRestriction()); + assertEquals(List.of(ouA), copy.getRegistrationOuDimensionItems()); + assertEquals(List.of(ouB), copy.getRegistrationOuFilterItems()); + assertEquals(List.of(ouA, ouB), copy.getAllRegistrationOuItems()); + } + + @Test + void testRegistrationOuIsIndependentOfEnrollmentOu() { + EventQueryParams params = + new EventQueryParams.Builder().withRegistrationOuDimension(List.of(ouA)).build(); + + assertTrue(params.hasRegistrationOuDimension()); + assertFalse(params.hasEnrollmentOuDimension()); + assertFalse(params.hasEnrollmentOu()); + } + + @Test + void testBareRegistrationOuDimensionCarriesNoItems() { + EventQueryParams params = + new EventQueryParams.Builder().withRegistrationOuDimension(List.of()).build(); + + assertTrue(params.hasRegistrationOuDimension()); + assertFalse(params.hasRegistrationOuRestriction()); + } + @Test void testHasDimensionValue() { EventQueryParams paramsA = diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java index 2a20b61d36b1..70bec8138215 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java @@ -32,6 +32,7 @@ import static org.apache.commons.lang3.StringUtils.EMPTY; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; @@ -1392,6 +1393,132 @@ void testAggregatedLegacyGroupByColumnsIncludesEnrollmentOuColumn() { assertTrue(columns.stream().anyMatch(c -> c.contains("enrl.\"ou\""))); } + @Test + void testRegistrationOuInWhereClause() { + OrganisationUnit ouA = createOrganisationUnit('A'); + OrganisationUnit ouB = createOrganisationUnit('B'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withRegistrationOuFilter(List.of(ouA, ouB)) + .build(); + + String whereClause = eventSubject.getWhereClause(params); + + assertThat(whereClause, containsString("regous.\"uidlevel1\"")); + assertThat(whereClause, containsString(ouA.getUid())); + assertThat(whereClause, containsString(ouB.getUid())); + } + + @Test + void testFromClauseIncludesRegistrationOuJoinWhenRegistrationOuIsUsed() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withTableName("analytics_event_test") + .withStartDate(from) + .withEndDate(to) + .withRegistrationOuDimension(List.of(ouA)) + .build(); + + String fromClause = eventSubject.getFromClause(params); + + assertThat(fromClause, containsString("inner join analytics_rs_orgunitstructure as regous")); + assertThat( + fromClause, containsString("on regous.\"organisationunituid\" = ax.\"registrationou\"")); + } + + @Test + void testFromClauseOmitsRegistrationOuJoinWhenUnused() { + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withTableName("analytics_event_test") + .withStartDate(from) + .withEndDate(to) + .build(); + + assertThat(eventSubject.getFromClause(params), not(containsString("regous"))); + } + + @Test + void testLegacySelectColumnsDoesNotIncludeRegistrationOuAggregateColumn() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withRegistrationOuDimension(List.of(ouA)) + .build(); + + List columns = eventSubject.getSelectColumns(params, false); + + assertTrue(columns.stream().noneMatch(c -> c.contains("registrationou"))); + } + + @Test + void testAggregatedLegacySelectColumnsIncludesRegistrationOuColumn() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withRegistrationOuDimension(List.of(ouA)) + .build(); + + List columns = eventSubject.getSelectColumns(params, true); + + assertTrue(columns.stream().anyMatch(c -> c.contains("as registrationou"))); + } + + @Test + void testAggregatedLegacyGroupByColumnsIncludesRegistrationOuColumn() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withRegistrationOuDimension(List.of(ouA)) + .build(); + + List columns = eventSubject.getGroupByColumnNames(params, true); + + assertTrue(columns.stream().anyMatch(c -> c.contains("regous.\"uidlevel1\""))); + } + + /** REGISTRATION_OU and ENROLLMENT_OU must be able to appear in one query without colliding. */ + @Test + void testRegistrationOuAndEnrollmentOuCoexist() { + OrganisationUnit ouA = createOrganisationUnit('A'); + OrganisationUnit ouB = createOrganisationUnit('B'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withTableName("analytics_event_test") + .withStartDate(from) + .withEndDate(to) + .withRegistrationOuDimension(List.of(ouA)) + .withEnrollmentOuDimension(List.of(ouB)) + .build(); + + String fromClause = eventSubject.getFromClause(params); + + assertThat(fromClause, containsString("as regous")); + assertThat(fromClause, containsString("as enrl")); + } + @Test void testEnrollmentOuInWhereClause() { OrganisationUnit ouA = createOrganisationUnit('A'); diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AggregatedRowBuilderTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AggregatedRowBuilderTest.java index 2865e535adc2..622f51e74aa9 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AggregatedRowBuilderTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AggregatedRowBuilderTest.java @@ -46,6 +46,7 @@ import java.util.function.BiFunction; import java.util.function.Function; import org.hisp.dhis.analytics.AggregationType; +import org.hisp.dhis.analytics.common.ColumnHeader; import org.hisp.dhis.analytics.event.EventQueryParams; import org.hisp.dhis.analytics.event.data.ou.OrgUnitRowAccess; import org.hisp.dhis.common.BaseDimensionalObject; @@ -475,4 +476,46 @@ void testBuildRowWithEnrollmentOuDimensionReadsEnrollmentOuColumn() { assertThat(row.get(0), is("ouUid")); assertThat(row.get(1), is(5)); } + + @Test + void testBuildRowWithRegistrationOuDimensionReadsRegistrationOuColumn() { + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withRegistrationOuDimension(List.of(createOrganisationUnit('A'))) + .build(); + + when(rowSet.getString(ColumnHeader.REGISTRATION_OU.getItem())).thenReturn("regOuUid"); + when(rowSet.getInt("value")).thenReturn(7); + + List row = + AggregatedRowBuilder.create(params, rowSet, sqlBuilder, columnAliasResolver, itemIdProvider) + .build(); + + verify(rowSet).getString(ColumnHeader.REGISTRATION_OU.getItem()); + assertThat(row, hasSize(2)); + assertThat(row.get(0), is("regOuUid")); + assertThat(row.get(1), is(7)); + } + + /** + * A bare dimension carries no items, so aggregate emits no column and the row must not read it. + */ + @Test + void testBuildRowWithBareRegistrationOuDimensionReadsNoColumn() { + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withRegistrationOuDimension(List.of()) + .build(); + + when(rowSet.getInt("value")).thenReturn(3); + + List row = + AggregatedRowBuilder.create(params, rowSet, sqlBuilder, columnAliasResolver, itemIdProvider) + .build(); + + assertThat(row, hasSize(1)); + assertThat(row.get(0), is(3)); + } } diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/DefaultEventDataQueryServiceTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/DefaultEventDataQueryServiceTest.java index 1a4e2a830da1..ed81f8425e39 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/DefaultEventDataQueryServiceTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/DefaultEventDataQueryServiceTest.java @@ -83,6 +83,7 @@ import org.hisp.dhis.program.ProgramService; import org.hisp.dhis.program.ProgramStage; import org.hisp.dhis.program.ProgramStageService; +import org.hisp.dhis.program.ProgramType; import org.hisp.dhis.trackedentity.TrackedEntityAttributeService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -502,6 +503,32 @@ void getFromRequestAcceptsAscendingCompletedSortForEnrollmentEndpoint() { assertEquals("completeddate", params.getAsc().get(0).getItemId()); } + /** + * The registration OU columns are projected under their own aliases, so sorting resolves to the + * alias rather than an analytics table column. + */ + @Test + void getFromRequestAcceptsRegistrationOuNameSort() { + EventDataQueryRequest request = + baseRequestBuilder(QUERY, EVENT).desc(Set.of("registrationouname")).build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertEquals(1, params.getDesc().size()); + assertEquals("registrationouname", params.getDesc().get(0).getItemId()); + } + + @Test + void getFromRequestAcceptsRegistrationOuSortForEnrollmentEndpoint() { + EventDataQueryRequest request = + baseRequestBuilder(QUERY, ENROLLMENT).asc(Set.of("registrationou")).build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertEquals(1, params.getAsc().size()); + assertEquals("registrationou", params.getAsc().get(0).getItemId()); + } + @Test void getFromRequestAcceptsDescendingCreatedSortForEventEndpoint() { EventDataQueryRequest request = @@ -793,6 +820,317 @@ void getFromRequestResolvesMixedEnrollmentOuLevelAndUidAsFilter() { assertEquals(Set.of(4), params.getEnrollmentOuFilterLevels()); } + @Test + void getFromRequestResolvesRegistrationOuAsDimension() { + OrganisationUnit ouA = createOrganisationUnit('B'); + OrganisationUnit ouB = createOrganisationUnit('C'); + + BaseDimensionalObject ouDimension = + new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(ouA, ouB)); + + when(dataQueryService.getDimension( + eq("ou"), + eq(List.of(ouA.getUid(), ouB.getUid())), + any(), + anyList(), + anyBoolean(), + any())) + .thenReturn(ouDimension); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .dimension(Set.of(Set.of("REGISTRATION_OU:" + ouA.getUid() + ";" + ouB.getUid()))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertTrue(params.hasRegistrationOuDimension()); + assertTrue(params.hasRegistrationOuRestriction()); + assertEquals(2, params.getRegistrationOuDimensionItems().size()); + assertFalse(params.hasEnrollmentOuDimension()); + } + + @Test + void getFromRequestResolvesRegistrationOuAsFilter() { + OrganisationUnit ouA = createOrganisationUnit('B'); + + BaseDimensionalObject ouDimension = + new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(ouA)); + + when(dataQueryService.getDimension( + eq("ou"), eq(List.of(ouA.getUid())), any(), anyList(), anyBoolean(), any())) + .thenReturn(ouDimension); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .filter(Set.of(Set.of("REGISTRATION_OU:" + ouA.getUid()))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertTrue(params.hasRegistrationOuFilter()); + assertFalse(params.hasRegistrationOuDimension()); + assertEquals(1, params.getRegistrationOuFilterItems().size()); + } + + /** + * The dimension keyword is spelled in upper case, and a lower case spelling is a different + * dimension that must not resolve as REGISTRATION_OU. The org unit dimension is stubbed so that a + * keyword match would produce a populated filter, which is what makes this assertion + * discriminate: without the stub the filter comes back empty either way. The stub is lenient + * because, once the comparison is exact, it is correctly never reached. + */ + @Test + void getFromRequestDoesNotResolveRegistrationOuSpelledInLowerCase() { + OrganisationUnit ouA = createOrganisationUnit('B'); + + lenient() + .when( + dataQueryService.getDimension( + eq("ou"), eq(List.of(ouA.getUid())), any(), anyList(), anyBoolean(), any())) + .thenReturn(new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(ouA))); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .filter(Set.of(Set.of("registration_ou:" + ouA.getUid()))) + .build(); + + assertFalse(subject.getFromRequest(request).hasRegistrationOuFilter()); + } + + /** + * REGISTRATION_OU delegates keyword expansion wholesale to the "ou" dimension, so LEVEL-n arrives + * back as concrete org units. This is the deliberate difference from ENROLLMENT_OU, which strips + * levels out and tracks them separately. + */ + @Test + void getFromRequestDelegatesRegistrationOuLevelKeywordToOuDimension() { + OrganisationUnit ouA = createOrganisationUnit('B'); + + BaseDimensionalObject ouDimension = + new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(ouA)); + + when(dataQueryService.getDimension( + eq("ou"), eq(List.of("LEVEL-m9lBJogzE95")), any(), anyList(), anyBoolean(), any())) + .thenReturn(ouDimension); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .dimension(Set.of(Set.of("REGISTRATION_OU:LEVEL-m9lBJogzE95"))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertTrue(params.hasRegistrationOuDimension()); + assertEquals(1, params.getRegistrationOuDimensionItems().size()); + } + + @Test + void getFromRequestKeepsRegistrationOuIndependentOfEnrollmentOu() { + OrganisationUnit registrationOu = createOrganisationUnit('B'); + OrganisationUnit enrollmentOu = createOrganisationUnit('C'); + + when(dataQueryService.getDimension( + eq("ou"), eq(List.of(registrationOu.getUid())), any(), anyList(), anyBoolean(), any())) + .thenReturn( + new BaseDimensionalObject( + "ou", DimensionType.ORGANISATION_UNIT, List.of(registrationOu))); + when(dataQueryService.getDimension( + eq("ou"), eq(List.of(enrollmentOu.getUid())), any(), anyList(), anyBoolean(), any())) + .thenReturn( + new BaseDimensionalObject( + "ou", DimensionType.ORGANISATION_UNIT, List.of(enrollmentOu))); + + Set> dimensions = new LinkedHashSet<>(); + dimensions.add(Set.of("REGISTRATION_OU:" + registrationOu.getUid())); + dimensions.add(Set.of("ENROLLMENT_OU:" + enrollmentOu.getUid())); + + EventQueryParams params = + subject.getFromRequest(baseRequestBuilder(AGGREGATE, EVENT).dimension(dimensions).build()); + + assertTrue(params.hasRegistrationOuDimension()); + assertTrue(params.hasEnrollmentOuDimension()); + assertEquals( + List.of(registrationOu.getUid()), + params.getRegistrationOuDimensionItems().stream().map(OrganisationUnit::getUid).toList()); + assertEquals( + List.of(enrollmentOu.getUid()), + params.getEnrollmentOuDimensionItems().stream().map(item -> item.getUid()).toList()); + } + + @Test + void getFromRequestRejectsRepeatedRegistrationOuDimension() { + OrganisationUnit ouA = createOrganisationUnit('B'); + OrganisationUnit ouB = createOrganisationUnit('C'); + + Set> dimensions = new LinkedHashSet<>(); + dimensions.add(Set.of("REGISTRATION_OU:" + ouA.getUid())); + dimensions.add(Set.of("REGISTRATION_OU:" + ouB.getUid())); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT).dimension(dimensions).build(); + + IllegalQueryException exception = + assertThrows(IllegalQueryException.class, () -> subject.getFromRequest(request)); + + assertEquals(ErrorCode.E7201, exception.getErrorCode()); + } + + @Test + void getFromRequestRejectsRepeatedRegistrationOuFilter() { + OrganisationUnit ouA = createOrganisationUnit('B'); + OrganisationUnit ouB = createOrganisationUnit('C'); + + Set> filters = new LinkedHashSet<>(); + filters.add(Set.of("REGISTRATION_OU:" + ouA.getUid())); + filters.add(Set.of("REGISTRATION_OU:" + ouB.getUid())); + + EventDataQueryRequest request = baseRequestBuilder(AGGREGATE, EVENT).filter(filters).build(); + + IllegalQueryException exception = + assertThrows(IllegalQueryException.class, () -> subject.getFromRequest(request)); + + assertEquals(ErrorCode.E7201, exception.getErrorCode()); + } + + /** Two occurrences can also arrive inside a single "_OR_" separated dimension group. */ + @Test + void getFromRequestRejectsRepeatedRegistrationOuWithinOneGroup() { + OrganisationUnit ouA = createOrganisationUnit('B'); + OrganisationUnit ouB = createOrganisationUnit('C'); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .dimension( + Set.of( + Set.of("REGISTRATION_OU:" + ouA.getUid(), "REGISTRATION_OU:" + ouB.getUid()))) + .build(); + + IllegalQueryException exception = + assertThrows(IllegalQueryException.class, () -> subject.getFromRequest(request)); + + assertEquals(ErrorCode.E7201, exception.getErrorCode()); + } + + @Test + void getFromRequestRejectsRepeatedEnrollmentOuDimension() { + OrganisationUnit ouA = createOrganisationUnit('B'); + OrganisationUnit ouB = createOrganisationUnit('C'); + + Set> dimensions = new LinkedHashSet<>(); + dimensions.add(Set.of("ENROLLMENT_OU:" + ouA.getUid())); + dimensions.add(Set.of("ENROLLMENT_OU:" + ouB.getUid())); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT).dimension(dimensions).build(); + + IllegalQueryException exception = + assertThrows(IllegalQueryException.class, () -> subject.getFromRequest(request)); + + assertEquals(ErrorCode.E7201, exception.getErrorCode()); + } + + /** Several org units in one dimension is one occurrence, and must not be rejected. */ + @Test + void getFromRequestAcceptsSeveralOrgUnitsInOneRegistrationOuDimension() { + OrganisationUnit ouA = createOrganisationUnit('B'); + OrganisationUnit ouB = createOrganisationUnit('C'); + + when(dataQueryService.getDimension( + eq("ou"), + eq(List.of(ouA.getUid(), ouB.getUid())), + any(), + anyList(), + anyBoolean(), + any())) + .thenReturn( + new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(ouA, ouB))); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .dimension(Set.of(Set.of("REGISTRATION_OU:" + ouA.getUid() + ";" + ouB.getUid()))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertEquals(2, params.getRegistrationOuDimensionItems().size()); + } + + /** The same dimension as both a dimension and a filter is a supported shape. */ + @Test + void getFromRequestAcceptsRegistrationOuAsDimensionAndFilterTogether() { + OrganisationUnit dimensionOu = createOrganisationUnit('B'); + OrganisationUnit filterOu = createOrganisationUnit('C'); + + when(dataQueryService.getDimension( + eq("ou"), eq(List.of(dimensionOu.getUid())), any(), anyList(), anyBoolean(), any())) + .thenReturn( + new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(dimensionOu))); + when(dataQueryService.getDimension( + eq("ou"), eq(List.of(filterOu.getUid())), any(), anyList(), anyBoolean(), any())) + .thenReturn( + new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(filterOu))); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .dimension(Set.of(Set.of("REGISTRATION_OU:" + dimensionOu.getUid()))) + .filter(Set.of(Set.of("REGISTRATION_OU:" + filterOu.getUid()))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertEquals( + List.of(dimensionOu.getUid()), + params.getRegistrationOuDimensionItems().stream().map(OrganisationUnit::getUid).toList()); + assertEquals( + List.of(filterOu.getUid()), + params.getRegistrationOuFilterItems().stream().map(OrganisationUnit::getUid).toList()); + } + + @Test + void getFromRequestRejectsRegistrationOuForProgramWithoutRegistration() { + program.setProgramType(ProgramType.WITHOUT_REGISTRATION); + + OrganisationUnit ouA = createOrganisationUnit('B'); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .dimension(Set.of(Set.of("REGISTRATION_OU:" + ouA.getUid()))) + .build(); + + IllegalQueryException exception = + assertThrows(IllegalQueryException.class, () -> subject.getFromRequest(request)); + + assertEquals(ErrorCode.E7258, exception.getErrorCode()); + } + + @Test + void getFromRequestRejectsBareRegistrationOuOnAggregate() { + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT).dimension(Set.of(Set.of("REGISTRATION_OU"))).build(); + + IllegalQueryException exception = + assertThrows(IllegalQueryException.class, () -> subject.getFromRequest(request)); + + assertEquals(ErrorCode.E7259, exception.getErrorCode()); + } + + /** + * A bare REGISTRATION_OU is legal on the query endpoints — it only projects the columns. It does + * not count as an org unit condition, so the query validator still has to see no items. + */ + @Test + void getFromRequestAcceptsBareRegistrationOuOnQueryWithoutContributingItems() { + EventDataQueryRequest request = + baseRequestBuilder(QUERY, EVENT).dimension(Set.of(Set.of("REGISTRATION_OU"))).build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertTrue(params.hasRegistrationOuDimension()); + assertFalse(params.hasRegistrationOuRestriction()); + assertTrue(params.getRegistrationOuDimensionItems().isEmpty()); + } + @Test void getFromRequestMergesMultiplePeriodDimensions() { BaseDimensionalObject peDimension = diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerCteTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerCteTest.java index e97109ed4199..72bdd4f9f3fb 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerCteTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerCteTest.java @@ -48,6 +48,7 @@ import static org.hisp.dhis.external.conf.ConfigurationKey.ANALYTICS_DATABASE; import static org.hisp.dhis.program.EnrollmentStatus.ACTIVE; import static org.hisp.dhis.program.EnrollmentStatus.COMPLETED; +import static org.hisp.dhis.test.TestBase.createOrganisationUnit; import static org.hisp.dhis.test.TestBase.createPeriodDimensions; import static org.hisp.dhis.test.TestBase.createProgram; import static org.hisp.dhis.test.TestBase.createProgramIndicator; @@ -84,6 +85,8 @@ import org.hisp.dhis.analytics.table.util.ColumnMapper; import org.hisp.dhis.common.AnalyticsCustomHeader; import org.hisp.dhis.common.BaseDimensionalItemObject; +import org.hisp.dhis.common.BaseDimensionalObject; +import org.hisp.dhis.common.DimensionType; import org.hisp.dhis.common.GridHeader; import org.hisp.dhis.common.QueryFilter; import org.hisp.dhis.common.QueryItem; @@ -98,6 +101,7 @@ import org.hisp.dhis.db.sql.PostgreSqlAnalyticsSqlBuilder; import org.hisp.dhis.external.conf.DefaultDhisConfigurationProvider; import org.hisp.dhis.option.OptionSet; +import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.period.PeriodDimension; import org.hisp.dhis.program.AnalyticsType; import org.hisp.dhis.program.Program; @@ -334,6 +338,83 @@ void verifyGetEnrollmentsWithStageOuDimensionExcludesOuNameAndCodeWithoutHeaders assertThat(generatedSql, not(containsString("ax.\"ps\""))); } + /** + * The base CTE strips table aliases from its projections, so an unqualified {@code uidlevelN} + * would be ambiguous between the enrollment table and the org unit structure join. The + * registration OU column must therefore stay qualified and carry the {@code registrationou} + * alias, which is also what the outer query groups by. + */ + @Test + void verifyAggregateEnrollmentProjectsRegistrationOuFromBaseCteUnambiguously() { + EventQueryParams params = + new EventQueryParams.Builder( + createRequestParamsBuilder().withEndpointAction(AGGREGATE).build()) + .withRegistrationOuDimension(List.of(createOrganisationUnit('R'))) + .build(); + + ListGrid grid = new ListGrid(); + grid.addHeader(new GridHeader("value", "Value", ValueType.NUMBER, false, false)); + grid.addHeader( + new GridHeader("registrationou", "Registration org unit", ValueType.TEXT, false, true)); + + subject.getEnrollments(params, grid, 10000); + verify(jdbcTemplate).queryForRowSet(sql.capture()); + + String generatedSql = noEof(sql.getValue()); + String baseCteSql = + generatedSql.substring( + generatedSql.indexOf("enrollment_aggr_base as ("), + generatedSql.indexOf("select count(eb.enrollment) as value")); + + // The registration OU column stays qualified inside the CTE and carries the output alias. + assertThat(baseCteSql, containsString("regous.\"uidlevel1\" as registrationou")); + // No unqualified uidlevel projection survives; that bare form is what Postgres rejected as + // "column reference uidlevel1 is ambiguous" once regous was joined. + assertThat(baseCteSql, not(containsString(", uidlevel1"))); + // The outer query selects and groups by the CTE's aliased column. + assertThat(generatedSql, containsString("from enrollment_aggr_base as eb")); + assertThat(generatedSql, containsString("group by \"registrationou\"")); + } + + /** + * When the org unit dimension is itself level-based it projects a uidlevelN column too, which + * collides with the org unit structure table joined for REGISTRATION_OU. Stripping the table + * alias from both leaves an ambiguous bare name, so the org unit dimension's projection has to + * stay qualified. + */ + @Test + void verifyAggregateEnrollmentKeepsOuLevelColumnQualifiedAlongsideRegistrationOu() { + OrganisationUnit district = createOrganisationUnit('D'); + + EventQueryParams params = + new EventQueryParams.Builder( + createRequestParamsBuilder().withEndpointAction(AGGREGATE).build()) + .addDimension( + new BaseDimensionalObject( + "uidlevel2", DimensionType.ORGANISATION_UNIT, List.of(district))) + .withRegistrationOuDimension(List.of(createOrganisationUnit('R'))) + .build(); + + ListGrid grid = new ListGrid(); + grid.addHeader(new GridHeader("value", "Value", ValueType.NUMBER, false, false)); + grid.addHeader( + new GridHeader("registrationou", "Registration org unit", ValueType.TEXT, false, true)); + + subject.getEnrollments(params, grid, 10000); + verify(jdbcTemplate).queryForRowSet(sql.capture()); + + String generatedSql = noEof(sql.getValue()); + String baseCteSql = + generatedSql.substring( + generatedSql.indexOf("enrollment_aggr_base as ("), + generatedSql.indexOf("select count(eb.enrollment) as value")); + + // The org unit dimension's own level column keeps its table qualifier. + assertThat(baseCteSql, containsString("ax.\"uidlevel2\"")); + // And no unqualified copy survives to be ambiguous against regous. + assertThat(baseCteSql, not(containsString(", uidlevel2"))); + } + @Test void verifyAggregateEnrollmentWithStageDateDimensionGeneratesValidSql() { // Test that aggregate enrollment queries with stage-specific EVENT_DATE: diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerTest.java index 01564a0f22b5..e1539a300d88 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerTest.java @@ -38,6 +38,7 @@ import static org.hisp.dhis.analytics.DataType.NUMERIC; import static org.hisp.dhis.external.conf.ConfigurationKey.ANALYTICS_DATABASE; import static org.hisp.dhis.system.util.SqlUtils.quote; +import static org.hisp.dhis.test.TestBase.createOrganisationUnit; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -47,6 +48,7 @@ import static org.mockito.Mockito.when; import java.sql.SQLException; +import java.util.List; import org.hisp.dhis.analytics.AggregationType; import org.hisp.dhis.analytics.analyze.ExecutionPlanStore; import org.hisp.dhis.analytics.event.EventQueryParams; @@ -68,6 +70,7 @@ import org.hisp.dhis.db.sql.PostgreSqlAnalyticsSqlBuilder; import org.hisp.dhis.db.sql.PostgreSqlBuilder; import org.hisp.dhis.external.conf.DefaultDhisConfigurationProvider; +import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.program.AnalyticsType; import org.hisp.dhis.program.ProgramIndicator; import org.hisp.dhis.program.ProgramIndicatorService; @@ -182,6 +185,66 @@ sqlBuilder, new ProgramStageOffsetSqlBuilder(sqlBuilder)), new DefaultStageDatePeriodBucketSqlRenderer(sqlBuilder))); } + @Test + void verifyRegistrationOuDimensionJoinsOrgUnitStructure() { + EventQueryParams params = + new EventQueryParams.Builder(createRequestParams()) + .withRegistrationOuDimension(List.of(createOrganisationUnit('R'))) + .build(); + + subject.getEnrollments(params, new ListGrid(), 10000); + + verify(jdbcTemplate).queryForRowSet(sql.capture()); + + // The SelectBuilder path renders the alias without "as"; the legacy string path uses "as". + assertThat(sql.getValue(), containsString("analytics_rs_orgunitstructure regous")); + assertThat( + sql.getValue(), + containsString("on regous.\"organisationunituid\" = ax.\"registrationou\"")); + } + + @Test + void verifyRegistrationOuFilterAppearsInWhereClause() { + OrganisationUnit ouR = createOrganisationUnit('R'); + + EventQueryParams params = + new EventQueryParams.Builder(createRequestParams()) + .withRegistrationOuFilter(List.of(ouR)) + .build(); + + subject.getEnrollments(params, new ListGrid(), 10000); + + verify(jdbcTemplate).queryForRowSet(sql.capture()); + + assertThat(sql.getValue(), containsString("regous.\"uidlevel1\"")); + assertThat(sql.getValue(), containsString(ouR.getUid())); + } + + @Test + void verifyRegistrationOuDimensionProjectsUidAndName() { + EventQueryParams params = + new EventQueryParams.Builder(createRequestParams()) + .withRegistrationOuDimension(List.of(createOrganisationUnit('R'))) + .build(); + + subject.getEnrollments(params, new ListGrid(), 10000); + + verify(jdbcTemplate).queryForRowSet(sql.capture()); + + assertThat(sql.getValue(), containsString("as registrationou")); + assertThat(sql.getValue(), containsString("as registrationouname")); + } + + @Test + void verifyNoRegistrationOuArtefactsWhenUnused() { + subject.getEnrollments(createRequestParams(), new ListGrid(), 10000); + + verify(jdbcTemplate).queryForRowSet(sql.capture()); + + assertThat(sql.getValue(), not(containsString("regous"))); + assertThat(sql.getValue(), not(containsString("registrationou"))); + } + @Test void verifySortsByCreatedDescending() { EventQueryParams params = diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventAggregateServiceTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventAggregateServiceTest.java index 63a92ceb46b0..b88f7aeb8dbf 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventAggregateServiceTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventAggregateServiceTest.java @@ -197,6 +197,22 @@ void shouldKeepValueAndEnrollmentOuHeadersUnchanged() throws Exception { assertEquals(VALUE_ID, headers.get(2).getName()); } + @Test + void shouldAddRegistrationOuHeaderAfterPeriod() throws Exception { + EventQueryParams params = + new EventQueryParams.Builder(defaultPeriodParams()) + .withRegistrationOuDimension(List.of(createOrganisationUnit('A'))) + .build(); + + Grid grid = new ListGrid(); + invokePrivate("addHeaders", EventQueryParams.class, Grid.class, params, grid); + + List headers = grid.getHeaders(); + assertEquals("pe", headers.get(0).getName()); + assertEquals(ColumnHeader.REGISTRATION_OU.getItem(), headers.get(1).getName()); + assertEquals(VALUE_ID, headers.get(2).getName()); + } + private GridHeader invokeAddDimensionHeaders(EventQueryParams params) throws Exception { return invokeDimensionHeadersGrid(params).getHeaders().get(0); } diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventQueryValidatorTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventQueryValidatorTest.java index 2d766114635c..a193b6b2f029 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventQueryValidatorTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventQueryValidatorTest.java @@ -177,6 +177,131 @@ void validateFailsWithoutOuAndWithoutEnrollmentOu() { assertEquals(ErrorCode.E7200, error.getErrorCode()); } + @Test + void validateSucceedsWithRegistrationOuAsOnlyOrgUnitDimension() { + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(prA) + .withStartDate(new DateTime(2010, 6, 1, 0, 0).toDate()) + .withEndDate(new DateTime(2012, 3, 20, 0, 0).toDate()) + .withRegistrationOuDimension(List.of(ouA)) + .build(); + + assertNull(eventQueryValidator.validateForErrorMessage(params)); + } + + @Test + void validateSucceedsWithRegistrationOuAsOnlyOrgUnitFilter() { + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(prA) + .withStartDate(new DateTime(2010, 6, 1, 0, 0).toDate()) + .withEndDate(new DateTime(2012, 3, 20, 0, 0).toDate()) + .withRegistrationOuFilter(List.of(ouA)) + .build(); + + assertNull(eventQueryValidator.validateForErrorMessage(params)); + } + + /** + * A REGISTRATION_OU dimension named without items projects columns but restricts nothing, so it + * must not satisfy the "at least one organisation unit" rule. + */ + @Test + void validateFailsWithBareRegistrationOuAsOnlyOrgUnitDimension() { + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(prA) + .withStartDate(new DateTime(2010, 6, 1, 0, 0).toDate()) + .withEndDate(new DateTime(2012, 3, 20, 0, 0).toDate()) + .withRegistrationOuDimension(List.of()) + .build(); + + ErrorMessage error = eventQueryValidator.validateForErrorMessage(params); + + assertEquals(ErrorCode.E7200, error.getErrorCode()); + } + + @Test + void validateFailsSortingByRegistrationOuNameWithoutRegistrationOuDimension() { + EventQueryParams params = + registrationOuSortParamsBuilder() + .withOrganisationUnits(List.of(ouA)) + .addDescSortItem(sortItem(ColumnHeader.REGISTRATION_OU_NAME.getItem())) + .build(); + + ErrorMessage error = eventQueryValidator.validateForErrorMessage(params); + + assertEquals(ErrorCode.E7261, error.getErrorCode()); + } + + /** + * The uid variant resolves to the physical registrationou column, so it produces no database + * error, but it sorts by the org unit the entity was registered in rather than the requested + * ancestor. Rejected for the same reason. + */ + @Test + void validateFailsSortingByRegistrationOuWithoutRegistrationOuDimension() { + EventQueryParams params = + registrationOuSortParamsBuilder() + .withOrganisationUnits(List.of(ouA)) + .addAscSortItem(sortItem(ColumnHeader.REGISTRATION_OU.getItem())) + .build(); + + ErrorMessage error = eventQueryValidator.validateForErrorMessage(params); + + assertEquals(ErrorCode.E7261, error.getErrorCode()); + } + + /** A filter joins the org unit structure table but projects no column to sort on. */ + @Test + void validateFailsSortingByRegistrationOuNameWithFilterOnly() { + EventQueryParams params = + registrationOuSortParamsBuilder() + .withRegistrationOuFilter(List.of(ouA)) + .addAscSortItem(sortItem(ColumnHeader.REGISTRATION_OU_NAME.getItem())) + .build(); + + ErrorMessage error = eventQueryValidator.validateForErrorMessage(params); + + assertEquals(ErrorCode.E7261, error.getErrorCode()); + } + + @Test + void validateSucceedsSortingByRegistrationOuNameWithRegistrationOuDimension() { + EventQueryParams params = + registrationOuSortParamsBuilder() + .withRegistrationOuDimension(List.of(ouA)) + .addDescSortItem(sortItem(ColumnHeader.REGISTRATION_OU_NAME.getItem())) + .build(); + + assertNull(eventQueryValidator.validateForErrorMessage(params)); + } + + /** A bare dimension projects both columns on the query endpoints, so its sort resolves. */ + @Test + void validateSucceedsSortingByRegistrationOuNameWithBareRegistrationOuDimension() { + EventQueryParams params = + registrationOuSortParamsBuilder() + .withOrganisationUnits(List.of(ouA)) + .withRegistrationOuDimension(List.of()) + .addDescSortItem(sortItem(ColumnHeader.REGISTRATION_OU_NAME.getItem())) + .build(); + + assertNull(eventQueryValidator.validateForErrorMessage(params)); + } + + private EventQueryParams.Builder registrationOuSortParamsBuilder() { + return new EventQueryParams.Builder() + .withProgram(prA) + .withStartDate(new DateTime(2010, 6, 1, 0, 0).toDate()) + .withEndDate(new DateTime(2012, 3, 20, 0, 0).toDate()); + } + + private QueryItem sortItem(String itemName) { + return new QueryItem(new BaseDimensionalItemObject(itemName)); + } + @Test void validateSingleDataElementMultipleProgramsQueryItemSuccess() { EventQueryParams params = diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlCoordinatorTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlCoordinatorTest.java new file mode 100644 index 000000000000..4884a15d31ce --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlCoordinatorTest.java @@ -0,0 +1,270 @@ +/* + * 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.event.data.registrationou; + +import static org.hisp.dhis.test.TestBase.createOrganisationUnit; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; +import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.analytics.util.sql.SelectBuilder; +import org.hisp.dhis.common.IllegalQueryException; +import org.hisp.dhis.commons.util.SqlHelper; +import org.hisp.dhis.db.sql.PostgreSqlBuilder; +import org.hisp.dhis.db.sql.SqlBuilder; +import org.hisp.dhis.feedback.ErrorCode; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.junit.jupiter.api.Test; + +class RegistrationOuSqlCoordinatorTest { + + private final SqlBuilder sqlBuilder = new PostgreSqlBuilder(); + + private final OrganisationUnit root = levelOne('A'); + private final OrganisationUnit districtA = childOf(root, 'B'); + private final OrganisationUnit districtB = childOf(root, 'C'); + + private static OrganisationUnit levelOne(char c) { + OrganisationUnit ou = createOrganisationUnit(c); + ou.updatePath(); + return ou; + } + + private static OrganisationUnit childOf(OrganisationUnit parent, char c) { + OrganisationUnit ou = createOrganisationUnit(c); + ou.setParent(parent); + ou.updatePath(); + return ou; + } + + // ------------------------------------------------------------------------- + // Join + // ------------------------------------------------------------------------- + + @Test + void testNoJoinWithoutRegistrationOu() { + assertEquals( + "", + RegistrationOuSqlCoordinator.joinClause( + new EventQueryParams.Builder().build(), sqlBuilder)); + } + + @Test + void testJoinAddedForDimension() { + assertTrue( + RegistrationOuSqlCoordinator.joinClause(dimensionParams(districtA), sqlBuilder) + .contains("inner join analytics_rs_orgunitstructure as regous")); + } + + @Test + void testJoinAddedForFilterOnly() { + assertTrue( + RegistrationOuSqlCoordinator.joinClause(filterParams(districtA), sqlBuilder) + .contains("inner join analytics_rs_orgunitstructure as regous")); + } + + /** A bare dimension still needs the join, because the query endpoint projects the OU name. */ + @Test + void testJoinAddedForBareDimension() { + assertTrue( + RegistrationOuSqlCoordinator.joinClause(dimensionParams(), sqlBuilder) + .contains("inner join analytics_rs_orgunitstructure as regous")); + } + + @Test + void testSelectBuilderJoinAddedForDimension() { + SelectBuilder sb = new SelectBuilder().addColumn("1").from("analytics_event_x", "ax"); + + RegistrationOuSqlCoordinator.addJoinIfNeeded(sb, dimensionParams(districtA), sqlBuilder); + + assertTrue( + sb.build() + .contains( + "analytics_rs_orgunitstructure regous " + + "on regous.\"organisationunituid\" = ax.\"registrationou\""), + sb.build()); + } + + @Test + void testSelectBuilderJoinOmittedWithoutRegistrationOu() { + SelectBuilder sb = new SelectBuilder().addColumn("1").from("analytics_event_x", "ax"); + + RegistrationOuSqlCoordinator.addJoinIfNeeded( + sb, new EventQueryParams.Builder().build(), sqlBuilder); + + assertTrue(!sb.build().contains("analytics_rs_orgunitstructure"), sb.build()); + } + + // ------------------------------------------------------------------------- + // Where predicate + // ------------------------------------------------------------------------- + + @Test + void testNoPredicateWithoutItems() { + assertEquals( + "", + RegistrationOuSqlCoordinator.wherePredicate( + dimensionParams(), new SqlHelper(), sqlBuilder)); + } + + @Test + void testPredicateForSingleLevel() { + assertEquals( + "where (regous.\"uidlevel2\" in ('" + + districtA.getUid() + + "','" + + districtB.getUid() + + "')) ", + RegistrationOuSqlCoordinator.wherePredicate( + dimensionParams(districtA, districtB), new SqlHelper(), sqlBuilder)); + } + + /** Items spanning levels union their subtrees, so the per-level predicates are OR-ed. */ + @Test + void testPredicateForMixedLevelsIsOred() { + assertEquals( + "where (regous.\"uidlevel1\" in ('" + + root.getUid() + + "') or regous.\"uidlevel2\" in ('" + + districtA.getUid() + + "')) ", + RegistrationOuSqlCoordinator.wherePredicate( + dimensionParams(root, districtA), new SqlHelper(), sqlBuilder)); + } + + /** A dimension and a filter both restrict, so they are AND-ed rather than OR-ed. */ + @Test + void testDimensionAndFilterAreAnded() { + EventQueryParams params = + new EventQueryParams.Builder() + .withRegistrationOuDimension(List.of(districtA)) + .withRegistrationOuFilter(List.of(districtB)) + .build(); + + assertEquals( + "where (regous.\"uidlevel2\" in ('" + + districtA.getUid() + + "')) and (regous.\"uidlevel2\" in ('" + + districtB.getUid() + + "')) ", + RegistrationOuSqlCoordinator.wherePredicate(params, new SqlHelper(), sqlBuilder)); + } + + // ------------------------------------------------------------------------- + // Aggregate select / group by + // ------------------------------------------------------------------------- + + @Test + void testNoAggregateColumnWhenNotAggregated() { + assertTrue( + RegistrationOuSqlCoordinator.dimensionSelectColumn( + dimensionParams(districtA), false, false, sqlBuilder) + .isEmpty()); + } + + @Test + void testNoAggregateColumnForFilterOnly() { + assertTrue( + RegistrationOuSqlCoordinator.dimensionSelectColumn( + filterParams(districtA), false, true, sqlBuilder) + .isEmpty()); + } + + @Test + void testAggregateGroupByColumn() { + assertEquals( + Optional.of("regous.\"uidlevel2\""), + RegistrationOuSqlCoordinator.dimensionSelectColumn( + dimensionParams(districtA), true, true, sqlBuilder)); + } + + @Test + void testAggregateProjectionColumn() { + assertEquals( + Optional.of("regous.\"uidlevel2\" as registrationou"), + RegistrationOuSqlCoordinator.dimensionSelectColumn( + dimensionParams(districtA), false, true, sqlBuilder)); + } + + /** + * One group-by column cannot represent org units at two levels, and an event below both is + * genuinely ambiguous, so this is rejected rather than silently resolved. + */ + @Test + void testAggregateRejectsMixedLevels() { + EventQueryParams params = dimensionParams(root, districtA); + + IllegalQueryException exception = + assertThrows( + IllegalQueryException.class, + () -> + RegistrationOuSqlCoordinator.dimensionSelectColumn( + params, false, true, sqlBuilder)); + + assertEquals(ErrorCode.E7260, exception.getErrorCode()); + } + + // ------------------------------------------------------------------------- + // Query select + // ------------------------------------------------------------------------- + + @Test + void testNoQueryColumnsWithoutDimension() { + assertTrue( + RegistrationOuSqlCoordinator.querySelectColumns(filterParams(districtA), sqlBuilder) + .isEmpty()); + } + + @Test + void testQueryColumnsForDimension() { + assertEquals( + List.of( + "regous.\"organisationunituid\" as registrationou", + "regous.\"name\" as registrationouname"), + RegistrationOuSqlCoordinator.querySelectColumns(dimensionParams(districtA), sqlBuilder)); + } + + @Test + void testQueryColumnsForBareDimension() { + assertEquals( + 2, RegistrationOuSqlCoordinator.querySelectColumns(dimensionParams(), sqlBuilder).size()); + } + + private EventQueryParams dimensionParams(OrganisationUnit... items) { + return new EventQueryParams.Builder().withRegistrationOuDimension(List.of(items)).build(); + } + + private EventQueryParams filterParams(OrganisationUnit... items) { + return new EventQueryParams.Builder().withRegistrationOuFilter(List.of(items)).build(); + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlFragmentsTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlFragmentsTest.java new file mode 100644 index 000000000000..28130a451fb3 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/registrationou/RegistrationOuSqlFragmentsTest.java @@ -0,0 +1,122 @@ +/* + * 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.event.data.registrationou; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.hisp.dhis.db.sql.DorisSqlBuilder; +import org.hisp.dhis.db.sql.PostgreSqlBuilder; +import org.hisp.dhis.db.sql.SqlBuilder; +import org.junit.jupiter.api.Test; + +class RegistrationOuSqlFragmentsTest { + + private final SqlBuilder pgSqlBuilder = new PostgreSqlBuilder(); + private final SqlBuilder dorisSqlBuilder = new DorisSqlBuilder("pg_dhis", "postgresql.jar"); + + @Test + void testJoinCondition() { + assertEquals( + "regous.\"organisationunituid\" = ax.\"registrationou\"", + RegistrationOuSqlFragments.joinCondition("regous", pgSqlBuilder)); + } + + @Test + void testJoinConditionDoris() { + assertEquals( + "regous.`organisationunituid` = ax.`registrationou`", + RegistrationOuSqlFragments.joinCondition("regous", dorisSqlBuilder)); + } + + @Test + void testInnerJoinClause() { + assertEquals( + "inner join analytics_rs_orgunitstructure as regous " + + "on regous.\"organisationunituid\" = ax.\"registrationou\" ", + RegistrationOuSqlFragments.innerJoinClause(pgSqlBuilder)); + } + + @Test + void testInnerJoinClauseDoris() { + assertEquals( + "inner join analytics_rs_orgunitstructure as regous " + + "on regous.`organisationunituid` = ax.`registrationou` ", + RegistrationOuSqlFragments.innerJoinClause(dorisSqlBuilder)); + } + + @Test + void testPredicateByUidLevel() { + assertEquals( + "regous.\"uidlevel2\" in ('abcdefghij1','abcdefghij2')", + RegistrationOuSqlFragments.predicateByUidLevel( + 2, "'abcdefghij1','abcdefghij2'", pgSqlBuilder)); + } + + @Test + void testPredicateByUidLevelDoris() { + assertEquals( + "regous.`uidlevel2` in ('abcdefghij1')", + RegistrationOuSqlFragments.predicateByUidLevel(2, "'abcdefghij1'", dorisSqlBuilder)); + } + + @Test + void testSelectUidLevelForGroupBy() { + assertEquals( + "regous.\"uidlevel3\"", RegistrationOuSqlFragments.selectUidLevel(3, true, pgSqlBuilder)); + } + + @Test + void testSelectUidLevelForProjection() { + assertEquals( + "regous.\"uidlevel3\" as registrationou", + RegistrationOuSqlFragments.selectUidLevel(3, false, pgSqlBuilder)); + } + + @Test + void testSelectRegistrationOuUid() { + assertEquals( + "regous.\"organisationunituid\" as registrationou", + RegistrationOuSqlFragments.selectRegistrationOuUid(pgSqlBuilder)); + } + + @Test + void testSelectRegistrationOuName() { + assertEquals( + "regous.\"name\" as registrationouname", + RegistrationOuSqlFragments.selectRegistrationOuName(pgSqlBuilder)); + } + + @Test + void testSelectRegistrationOuNameDoris() { + assertEquals( + "regous.`name` as registrationouname", + RegistrationOuSqlFragments.selectRegistrationOuName(dorisSqlBuilder)); + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/security/DefaultAnalyticsSecurityManagerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/security/DefaultAnalyticsSecurityManagerTest.java index 6d8374f562ab..ae5e502816f5 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/security/DefaultAnalyticsSecurityManagerTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/security/DefaultAnalyticsSecurityManagerTest.java @@ -30,18 +30,21 @@ package org.hisp.dhis.analytics.security; import static org.hisp.dhis.common.DimensionConstants.ORGUNIT_DIM_ID; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.when; import java.util.List; import java.util.Set; import org.hisp.dhis.analytics.event.EventQueryParams; import org.hisp.dhis.common.DimensionService; import org.hisp.dhis.common.DimensionalItemObject; +import org.hisp.dhis.common.IllegalQueryException; import org.hisp.dhis.dataapproval.DataApprovalLevelService; +import org.hisp.dhis.feedback.ErrorCode; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.security.acl.AclService; import org.hisp.dhis.setting.SystemSettingsProvider; @@ -78,14 +81,15 @@ void setUp() { ouA = createOrganisationUnit('A'); ouB = createOrganisationUnit('B'); - when(userService.getUserByUsername(nullable(String.class))).thenReturn(currentUser); - when(currentUser.isSuper()).thenReturn(true); - when(currentUser.getDimensionConstraints()).thenReturn(Set.of()); + lenient().when(userService.getUserByUsername(nullable(String.class))).thenReturn(currentUser); + lenient().when(currentUser.isSuper()).thenReturn(true); + lenient().when(currentUser.getDimensionConstraints()).thenReturn(Set.of()); lenient().when(currentUser.hasDataViewOrganisationUnit()).thenReturn(true); lenient().when(currentUser.getDataViewOrganisationUnits()).thenReturn(Set.of(ouB)); lenient().when(currentUser.getUsername()).thenReturn("tester"); - UserDetails currentUserDetails = UserDetails.empty().username("tester").build(); + UserDetails currentUserDetails = + UserDetails.empty().username("tester").userDataOrgUnitIds(Set.of(ouB.getUid())).build(); CurrentUserUtil.injectUserInSecurityContext(currentUserDetails); } @@ -121,6 +125,53 @@ void shouldKeepExplicitOuWhenEnrollmentOuIsAlsoUsed() { assertEquals(ouA.getUid(), constrainedOus.get(0).getUid()); } + /** + * REGISTRATION_OU deliberately does not suppress the data view constraint the way ENROLLMENT_OU + * does, so an OU-less request is still clipped to the caller's scope. + */ + @Test + void shouldAssignDefaultOuConstraintWhenRegistrationOuIsUsedWithoutExplicitOu() { + EventQueryParams params = + new EventQueryParams.Builder().withRegistrationOuFilter(List.of(ouB)).build(); + + EventQueryParams constrained = securityManager.withUserConstraints(params); + List constrainedOus = + constrained.getDimensionOrFilterItems(ORGUNIT_DIM_ID); + + assertEquals(1, constrainedOus.size()); + assertEquals(ouB.getUid(), constrainedOus.get(0).getUid()); + } + + @Test + void shouldRejectRegistrationOuDimensionOutsideDataViewScope() { + EventQueryParams params = + new EventQueryParams.Builder().withRegistrationOuDimension(List.of(ouA)).build(); + + IllegalQueryException exception = + assertThrows(IllegalQueryException.class, () -> securityManager.decideAccess(params)); + + assertEquals(ErrorCode.E7120, exception.getErrorCode()); + } + + @Test + void shouldRejectRegistrationOuFilterOutsideDataViewScope() { + EventQueryParams params = + new EventQueryParams.Builder().withRegistrationOuFilter(List.of(ouA)).build(); + + IllegalQueryException exception = + assertThrows(IllegalQueryException.class, () -> securityManager.decideAccess(params)); + + assertEquals(ErrorCode.E7120, exception.getErrorCode()); + } + + @Test + void shouldAcceptRegistrationOuWithinDataViewScope() { + EventQueryParams params = + new EventQueryParams.Builder().withRegistrationOuDimension(List.of(ouB)).build(); + + assertDoesNotThrow(() -> securityManager.decideAccess(params)); + } + @Test void shouldAssignDefaultOuConstraintWhenNoOuAndNoEnrollmentOuAreProvided() { EventQueryParams params = new EventQueryParams.Builder().build(); diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/HeaderHelperTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/HeaderHelperTest.java index 4a18271b91b0..9e637df63f86 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/HeaderHelperTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/HeaderHelperTest.java @@ -38,9 +38,11 @@ import static org.hisp.dhis.test.TestBase.createLegendSet; import static org.hisp.dhis.test.TestBase.createOption; import static org.hisp.dhis.test.TestBase.createOptionSet; +import static org.hisp.dhis.test.TestBase.createOrganisationUnit; import static org.hisp.dhis.test.TestBase.createProgram; import static org.hisp.dhis.test.TestBase.createProgramStage; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -56,6 +58,7 @@ import org.hisp.dhis.common.GridHeader; import org.hisp.dhis.common.QueryItem; import org.hisp.dhis.common.RepeatableStageParams; +import org.hisp.dhis.common.RequestTypeAware; import org.hisp.dhis.common.ValueType; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.legend.Legend; @@ -372,6 +375,55 @@ void enrollmentOuHeadersPrecedeItemHeaders() { assertEquals("deUidA001", headers.get(2).getName()); } + @Test + @DisplayName("registrationou/registrationouname headers precede item headers on query endpoints") + void registrationOuHeadersPrecedeItemHeaders() { + Grid grid = new ListGrid(); + + QueryItem item = queryItem("deUidA001", "Item A", TEXT); + + EventQueryParams params = + new EventQueryParams.Builder() + .addItem(item) + .withRegistrationOuDimension(List.of(createOrganisationUnit('R'))) + .withDisplayProperty(DisplayProperty.NAME) + .build(); + + HeaderHelper.addCommonHeaders(grid, params, List.of()); + + List headers = grid.getHeaders(); + + assertEquals(3, headers.size()); + assertEquals("registrationou", headers.get(0).getName()); + assertEquals("registrationouname", headers.get(1).getName()); + assertEquals("deUidA001", headers.get(2).getName()); + } + + /** + * The aggregate grid carries one column per dimension, and the base CTE only projects the org + * unit uid, so the name header must not be added there. + */ + @Test + @DisplayName("enrollment aggregate adds only the registrationou header, not the name") + void registrationOuAggregateAddsUidHeaderOnly() { + Grid grid = new ListGrid(); + + EventQueryParams params = + new EventQueryParams.Builder() + .withEndpointItem(RequestTypeAware.EndpointItem.ENROLLMENT) + .withEndpointAction(RequestTypeAware.EndpointAction.AGGREGATE) + .withRegistrationOuDimension(List.of(createOrganisationUnit('R'))) + .withDisplayProperty(DisplayProperty.NAME) + .build(); + + HeaderHelper.addCommonHeaders(grid, params, List.of()); + + List names = grid.getHeaders().stream().map(GridHeader::getName).toList(); + + assertTrue(names.contains("registrationou"), names.toString()); + assertFalse(names.contains("registrationouname"), names.toString()); + } + @Test @DisplayName("stage.ou without headers param should only add ou header") void stageOuWithNoHeadersParam() { diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandlerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandlerTest.java index 193e009e775c..600f710505bb 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandlerTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandlerTest.java @@ -2074,6 +2074,103 @@ void shouldIncludeEnrollmentOuDimensionItemsAndMetadata() { } } + @Nested + @DisplayName("Registration OU Dimension Tests") + class RegistrationOuDimensionTests { + + @Test + @DisplayName("should include registration OU dimension items and metadata") + void shouldIncludeRegistrationOuDimensionItemsAndMetadata() { + Grid grid = new ListGrid(); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withPeriods(createPeriodDimensions("2023Q1"), "quarterly") + .withRegistrationOuDimension(List.of(orgUnitA, orgUnitB)) + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + + metadataItemsHandler.addMetadata(grid, params, List.of()); + + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertNotNull(dimensions); + assertTrue(dimensions.containsKey("registrationou")); + assertEquals(2, dimensions.get("registrationou").size()); + assertTrue(dimensions.get("registrationou").contains(orgUnitA.getUid())); + assertTrue(dimensions.get("registrationou").contains(orgUnitB.getUid())); + + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertNotNull(items); + assertTrue(items.containsKey(orgUnitA.getUid())); + assertTrue(items.containsKey(orgUnitB.getUid())); + assertTrue(items.containsKey("registrationou")); + // The ticket specifies the unabbreviated form, unlike the enrollment OU equivalent. + assertEquals("Registration org unit", ((MetadataItem) items.get("registrationou")).getName()); + } + + @Test + @DisplayName("should include registration OU metadata on the query endpoints too") + void shouldIncludeRegistrationOuMetadataOnQuery() { + Grid grid = new ListGrid(); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(QUERY) + .withOrganisationUnits(List.of(orgUnitA)) + .withRegistrationOuDimension(List.of(orgUnitB)) + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + + metadataItemsHandler.addMetadata(grid, params, List.of()); + + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertTrue(dimensions.containsKey("registrationou")); + assertEquals(List.of(orgUnitB.getUid()), dimensions.get("registrationou")); + + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertTrue(items.containsKey("registrationou")); + assertTrue(items.containsKey(orgUnitB.getUid())); + } + + @Test + @DisplayName("a bare dimension carries no items, so no dimension entry is emitted") + void shouldOmitRegistrationOuWhenBare() { + Grid grid = new ListGrid(); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(QUERY) + .withOrganisationUnits(List.of(orgUnitA)) + .withRegistrationOuDimension(List.of()) + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + + metadataItemsHandler.addMetadata(grid, params, List.of()); + + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertFalse(dimensions.containsKey("registrationou")); + } + } + @Nested @DisplayName("Program Status Metadata Tests") class ProgramStatusMetadataTests { diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/analytics/common/ColumnHeader.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/analytics/common/ColumnHeader.java index be665d4eec4b..05559e1acad7 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/analytics/common/ColumnHeader.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/analytics/common/ColumnHeader.java @@ -67,6 +67,8 @@ public enum ColumnHeader { EVENT_STATUS("eventstatus", "Event status"), ENROLLMENT_OU("enrollmentou", "Enrollment org unit"), ENROLLMENT_OU_NAME("enrollmentouname", "Enrollment org unit name"), + REGISTRATION_OU("registrationou", "Registration org unit"), + REGISTRATION_OU_NAME("registrationouname", "Registration org unit name"), DIMENSION("dx", "Data"), DIMENSION_NAME("dxname", "Data name"), PERIOD("pe", "Period"), diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/aggregate/EnrollmentsAggregate8AutoTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/aggregate/EnrollmentsAggregate8AutoTest.java new file mode 100644 index 000000000000..99e40e4befe7 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/aggregate/EnrollmentsAggregate8AutoTest.java @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2004-2024, 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.enrollment.aggregate; + +import static org.hisp.dhis.analytics.ValidationHelper.validateHeaderPropertiesByName; +import static org.hisp.dhis.analytics.ValidationHelper.validateResponseStructure; +import static org.hisp.dhis.analytics.ValidationHelper.validateRowExists; +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.hisp.dhis.AnalyticsApiTest; +import org.hisp.dhis.test.e2e.actions.analytics.AnalyticsEnrollmentsActions; +import org.hisp.dhis.test.e2e.dto.ApiResponse; +import org.hisp.dhis.test.e2e.helpers.QueryParamsBuilder; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +/** Groups e2e tests for "/enrollments/aggregate" endpoint. */ +public class EnrollmentsAggregate8AutoTest extends AnalyticsApiTest { + private final AnalyticsEnrollmentsActions actions = new AnalyticsEnrollmentsActions(); + + @Test + public void registrationOuWithLevel2() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("sortOrder=asc") + .add("totalPages=false") + .add("dimension=pe:2022,REGISTRATION_OU:LEVEL-2"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 3, + 3, + 3); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":50,\"isLastPage\":true},\"items\":{\"jUb8gELQApl\":{\"name\":\"Kailahun\"},\"TEQlaapDQoK\":{\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"name\":\"Tonkolili\"},\"Vth0fbpFcsO\":{\"name\":\"Kono\"},\"ou\":{},\"PMa2VCrupOd\":{\"name\":\"Kambia\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"bL4ooGhyHRQ\":{\"name\":\"Pujehun\"},\"kJq2mPyFEHo\":{\"name\":\"Kenema\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"},\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"at6UHUQatSo\":{\"name\":\"Western Area\"},\"pe\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"lc3eMKXaEfw\":{\"name\":\"Bonthe\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"},\"qhqAxPSTUXp\":{\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"name\":\"Moyamba\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists( + response, + actualHeaders, + Map.of("value", "1", "pe", "2022", "registrationou", "O6uvpzGd5pu")); + + // Validate row exists with values from original row index 2 + validateRowExists( + response, + actualHeaders, + Map.of("value", "3", "pe", "2022", "registrationou", "jUb8gELQApl")); + } + + @Test + public void registrationOuWithMultipleOus() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("sortOrder=asc") + .add("totalPages=false") + .add("dimension=pe:2022,REGISTRATION_OU:O6uvpzGd5pu;fdc6uOvgoji"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 2, + 3, + 3); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":50,\"isLastPage\":true},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"pe\":{},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists( + response, + actualHeaders, + Map.of("value", "1", "pe", "2022", "registrationou", "O6uvpzGd5pu")); + + // Validate row exists with values from original row index 1 + validateRowExists( + response, + actualHeaders, + Map.of("value", "2", "pe", "2022", "registrationou", "fdc6uOvgoji")); + } + + @Test + public void registrationOuAsFilter() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("filter=REGISTRATION_OU:jUb8gELQApl") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("dimension=pe:2022"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 1, + 2, + 2); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":50,\"isLastPage\":true},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"pe\":{},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"ImspTQPwCqd\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists(response, actualHeaders, Map.of("value", "3", "pe", "2022")); + } + + @Test + public void registrationOuWithUserOrgUnit() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("totalPages=false") + .add("dimension=pe:2022,REGISTRATION_OU:USER_ORGUNIT"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 1, + 3, + 3); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":50,\"isLastPage\":true},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"pe\":{},\"USER_ORGUNIT\":{\"organisationUnits\":[\"ImspTQPwCqd\"]},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"ImspTQPwCqd\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists( + response, + actualHeaders, + Map.of("value", "6", "pe", "2022", "registrationou", "ImspTQPwCqd")); + } + + @Test + public void registrationOuCombinedWithEnrollmentOu() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("totalPages=false") + .add("dimension=pe:2022,ou:LEVEL-2,REGISTRATION_OU:O6uvpzGd5pu"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 1, + 4, + 4); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":50,\"isLastPage\":true},\"items\":{\"jUb8gELQApl\":{\"name\":\"Kailahun\"},\"TEQlaapDQoK\":{\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"name\":\"Tonkolili\"},\"Vth0fbpFcsO\":{\"name\":\"Kono\"},\"PMa2VCrupOd\":{\"name\":\"Kambia\"},\"ou\":{\"name\":\"Organisation unit\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"bL4ooGhyHRQ\":{\"name\":\"Pujehun\"},\"2022\":{\"name\":\"2022\"},\"kJq2mPyFEHo\":{\"name\":\"Kenema\"},\"wjP19dkFeIk\":{\"uid\":\"wjP19dkFeIk\",\"name\":\"District\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"},\"at6UHUQatSo\":{\"name\":\"Western Area\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"pe\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"lc3eMKXaEfw\":{\"name\":\"Bonthe\"},\"qhqAxPSTUXp\":{\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"name\":\"Moyamba\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"],\"registrationou\":[\"O6uvpzGd5pu\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + validateHeaderPropertiesByName( + response, + actualHeaders, + "ou", + "Organisation unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists( + response, + actualHeaders, + Map.of("value", "1", "ou", "fdc6uOvgoji", "pe", "2022", "registrationou", "O6uvpzGd5pu")); + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/query/EnrollmentsQuery8AutoTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/query/EnrollmentsQuery8AutoTest.java new file mode 100644 index 000000000000..bec7fb53f6f8 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/query/EnrollmentsQuery8AutoTest.java @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2004-2024, 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.enrollment.query; + +import static org.hisp.dhis.analytics.ValidationHelper.validateHeaderPropertiesByName; +import static org.hisp.dhis.analytics.ValidationHelper.validateResponseStructure; +import static org.hisp.dhis.analytics.ValidationHelper.validateRowValueByName; +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.hisp.dhis.AnalyticsApiTest; +import org.hisp.dhis.test.e2e.actions.analytics.AnalyticsEnrollmentsActions; +import org.hisp.dhis.test.e2e.dto.ApiResponse; +import org.hisp.dhis.test.e2e.helpers.QueryParamsBuilder; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +public class EnrollmentsQuery8AutoTest extends AnalyticsApiTest { + private final AnalyticsEnrollmentsActions actions = new AnalyticsEnrollmentsActions(); + + @Test + public void registrationOuWithBothHeadersAndEnrollmentOuName() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("headers=registrationou,registrationouname,ouname") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("pageSize=10") + .add("page=1") + .add("dimension=pe:2022,REGISTRATION_OU:O6uvpzGd5pu"); + + // When + ApiResponse response = actions.query().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 1, + 3, + 3); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":10,\"isLastPage\":true},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"pe\":{},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationouname", + "Registration org unit name", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "ouname", + "Organisation unit name", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row values by name at specific indices (sorted results). + // Validate selected values for row index 0 + validateRowValueByName(response, actualHeaders, 0, "registrationou", "aBfyTU5Wgds"); + validateRowValueByName(response, actualHeaders, 0, "ouname", "Tambiama CHC"); + } + + @Test + public void registrationOuWithLevel2SortedAscending() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("asc=registrationouname") + .add("headers=registrationou,registrationouname") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("pageSize=10") + .add("page=1") + .add("dimension=pe:2022,REGISTRATION_OU:LEVEL-2"); + + // When + ApiResponse response = actions.query().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 6, + 2, + 2); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":10,\"isLastPage\":true},\"items\":{\"jUb8gELQApl\":{\"name\":\"Kailahun\"},\"TEQlaapDQoK\":{\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"name\":\"Tonkolili\"},\"Vth0fbpFcsO\":{\"name\":\"Kono\"},\"ou\":{},\"PMa2VCrupOd\":{\"name\":\"Kambia\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"bL4ooGhyHRQ\":{\"name\":\"Pujehun\"},\"kJq2mPyFEHo\":{\"name\":\"Kenema\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"},\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"at6UHUQatSo\":{\"name\":\"Western Area\"},\"pe\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"lc3eMKXaEfw\":{\"name\":\"Bonthe\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"},\"qhqAxPSTUXp\":{\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"name\":\"Moyamba\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationouname", + "Registration org unit name", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row values by name at specific indices (sorted results). + // Validate selected values for row index 0 + validateRowValueByName(response, actualHeaders, 0, "registrationou", "aBfyTU5Wgds"); + validateRowValueByName(response, actualHeaders, 0, "registrationouname", "Nduvuibu MCHP"); + + // Validate selected values for row index 2 + validateRowValueByName(response, actualHeaders, 2, "registrationou", "a1E6QWBTEwX"); + validateRowValueByName(response, actualHeaders, 2, "registrationouname", "Sienga CHP"); + + // Validate selected values for row index 4 + validateRowValueByName(response, actualHeaders, 4, "registrationou", "agEKP19IUKI"); + validateRowValueByName(response, actualHeaders, 4, "registrationouname", "Tambiama CHC"); + + // Validate selected values for row index 5 + validateRowValueByName(response, actualHeaders, 5, "registrationou", "agEKP19IUKI"); + validateRowValueByName(response, actualHeaders, 5, "registrationouname", "Tambiama CHC"); + } + + @Test + public void registrationOuAsFilter() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("filter=REGISTRATION_OU:jUb8gELQApl") + .add("headers=ouname") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("pageSize=10") + .add("page=1") + .add("dimension=pe:2022"); + + // When + ApiResponse response = actions.query().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 3, + 1, + 1); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":10,\"isLastPage\":true},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"pe\":{},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "ouname", + "Organisation unit name", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row values by name at specific indices (sorted results). + // Validate selected values for row index 0 + validateRowValueByName(response, actualHeaders, 0, "ouname", "Nduvuibu MCHP"); + + // Validate selected values for row index 2 + validateRowValueByName(response, actualHeaders, 2, "ouname", "Nduvuibu MCHP"); + } + + @Test + public void registrationOuSortedDescending() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("headers=registrationouname") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("pageSize=10") + .add("page=1") + .add("dimension=pe:2022,REGISTRATION_OU:LEVEL-2") + .add("desc=registrationouname"); + + // When + ApiResponse response = actions.query().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 6, + 1, + 1); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":10,\"isLastPage\":true},\"items\":{\"jUb8gELQApl\":{\"name\":\"Kailahun\"},\"TEQlaapDQoK\":{\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"name\":\"Tonkolili\"},\"Vth0fbpFcsO\":{\"name\":\"Kono\"},\"ou\":{},\"PMa2VCrupOd\":{\"name\":\"Kambia\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"bL4ooGhyHRQ\":{\"name\":\"Pujehun\"},\"kJq2mPyFEHo\":{\"name\":\"Kenema\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"},\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"at6UHUQatSo\":{\"name\":\"Western Area\"},\"pe\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"lc3eMKXaEfw\":{\"name\":\"Bonthe\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"},\"qhqAxPSTUXp\":{\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"name\":\"Moyamba\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationouname", + "Registration org unit name", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row values by name at specific indices (sorted results). + // Validate selected values for row index 0 + validateRowValueByName(response, actualHeaders, 0, "registrationouname", "Tambiama CHC"); + + // Validate selected values for row index 2 + validateRowValueByName(response, actualHeaders, 2, "registrationouname", "Sienga CHP"); + + // Validate selected values for row index 4 + validateRowValueByName(response, actualHeaders, 4, "registrationouname", "Sienga CHP"); + + // Validate selected values for row index 5 + validateRowValueByName(response, actualHeaders, 5, "registrationouname", "Nduvuibu MCHP"); + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate12AutoTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate12AutoTest.java new file mode 100644 index 000000000000..54cb2057164e --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate12AutoTest.java @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2004-2024, 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.event.aggregate; + +import static org.hisp.dhis.analytics.ValidationHelper.validateHeaderPropertiesByName; +import static org.hisp.dhis.analytics.ValidationHelper.validateResponseStructure; +import static org.hisp.dhis.analytics.ValidationHelper.validateRowExists; +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.hisp.dhis.AnalyticsApiTest; +import org.hisp.dhis.test.e2e.actions.analytics.AnalyticsEventActions; +import org.hisp.dhis.test.e2e.dto.ApiResponse; +import org.hisp.dhis.test.e2e.helpers.QueryParamsBuilder; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +/** Groups e2e tests for "/events/aggregate" endpoint. */ +public class EventsAggregate12AutoTest extends AnalyticsApiTest { + private final AnalyticsEventActions actions = new AnalyticsEventActions(); + + @Test + public void registrationOuWithLevel2() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("sortOrder=asc") + .add("totalPages=false") + .add("dimension=pe:2022,REGISTRATION_OU:LEVEL-2"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 3, + 3, + 3); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"items\":{\"jUb8gELQApl\":{\"name\":\"Kailahun\"},\"TEQlaapDQoK\":{\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"name\":\"Tonkolili\"},\"Vth0fbpFcsO\":{\"name\":\"Kono\"},\"ou\":{},\"PMa2VCrupOd\":{\"name\":\"Kambia\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"bL4ooGhyHRQ\":{\"name\":\"Pujehun\"},\"kJq2mPyFEHo\":{\"name\":\"Kenema\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"},\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"at6UHUQatSo\":{\"name\":\"Western Area\"},\"pe\":{\"name\":\"Period\"},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"lc3eMKXaEfw\":{\"name\":\"Bonthe\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"},\"qhqAxPSTUXp\":{\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"name\":\"Moyamba\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists( + response, + actualHeaders, + Map.of("pe", "2022", "registrationou", "O6uvpzGd5pu", "value", "1")); + + // Validate row exists with values from original row index 2 + validateRowExists( + response, + actualHeaders, + Map.of("pe", "2022", "registrationou", "jUb8gELQApl", "value", "3")); + } + + @Test + public void registrationOuWithMultipleOus() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("sortOrder=asc") + .add("totalPages=false") + .add("dimension=pe:2022,REGISTRATION_OU:O6uvpzGd5pu;fdc6uOvgoji"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 2, + 3, + 3); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"pe\":{\"name\":\"Period\"},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists( + response, + actualHeaders, + Map.of("pe", "2022", "registrationou", "O6uvpzGd5pu", "value", "1")); + + // Validate row exists with values from original row index 1 + validateRowExists( + response, + actualHeaders, + Map.of("pe", "2022", "registrationou", "fdc6uOvgoji", "value", "2")); + } + + @Test + public void registrationOuAsFilter() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("filter=REGISTRATION_OU:jUb8gELQApl") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("dimension=pe:2022"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 1, + 2, + 2); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"pe\":{\"name\":\"Period\"},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"ImspTQPwCqd\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists(response, actualHeaders, Map.of("pe", "2022", "value", "3")); + } + + @Test + public void registrationOuWithUserOrgUnit() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("totalPages=false") + .add("dimension=pe:2022,REGISTRATION_OU:USER_ORGUNIT"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 1, + 3, + 3); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"pe\":{\"name\":\"Period\"},\"USER_ORGUNIT\":{\"organisationUnits\":[\"ImspTQPwCqd\"]},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"ImspTQPwCqd\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists( + response, + actualHeaders, + Map.of("pe", "2022", "registrationou", "ImspTQPwCqd", "value", "6")); + } + + @Test + public void registrationOuCombinedWithEventOu() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("totalPages=false") + .add("dimension=pe:2022,ou:LEVEL-2,REGISTRATION_OU:O6uvpzGd5pu"); + + // When + ApiResponse response = actions.aggregate().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 1, + 4, + 4); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"items\":{\"jUb8gELQApl\":{\"name\":\"Kailahun\"},\"TEQlaapDQoK\":{\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"name\":\"Tonkolili\"},\"Vth0fbpFcsO\":{\"name\":\"Kono\"},\"PMa2VCrupOd\":{\"name\":\"Kambia\"},\"ou\":{\"name\":\"Organisation unit\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"bL4ooGhyHRQ\":{\"name\":\"Pujehun\"},\"2022\":{\"name\":\"2022\"},\"kJq2mPyFEHo\":{\"name\":\"Kenema\"},\"wjP19dkFeIk\":{\"uid\":\"wjP19dkFeIk\",\"name\":\"District\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"},\"at6UHUQatSo\":{\"name\":\"Western Area\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"pe\":{\"name\":\"Period\"},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"lc3eMKXaEfw\":{\"name\":\"Bonthe\"},\"qhqAxPSTUXp\":{\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"name\":\"Moyamba\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"],\"registrationou\":[\"O6uvpzGd5pu\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "ou", + "Organisation unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists( + response, + actualHeaders, + Map.of("ou", "jUb8gELQApl", "pe", "2022", "registrationou", "O6uvpzGd5pu", "value", "1")); + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventQueryRegistrationOuValidationTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventQueryRegistrationOuValidationTest.java new file mode 100644 index 000000000000..9a5108b39e29 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventQueryRegistrationOuValidationTest.java @@ -0,0 +1,199 @@ +/* + * 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.event.query; + +import static org.hamcrest.Matchers.equalTo; + +import org.hisp.dhis.AnalyticsApiTest; +import org.hisp.dhis.test.e2e.actions.analytics.AnalyticsEnrollmentsActions; +import org.hisp.dhis.test.e2e.actions.analytics.AnalyticsEventActions; +import org.hisp.dhis.test.e2e.dto.ApiResponse; +import org.hisp.dhis.test.e2e.helpers.QueryParamsBuilder; +import org.junit.jupiter.api.Test; + +/** + * Groups e2e tests for the request shapes REGISTRATION_OU must reject. The generator only supports + * happy paths, so these are written by hand. + */ +public class EventQueryRegistrationOuValidationTest extends AnalyticsApiTest { + private static final String PROGRAM = "regOuProg01"; + + private static final String BO = "O6uvpzGd5pu"; + + private static final String BOMBALI = "fdc6uOvgoji"; + + private static final String KAILAHUN = "jUb8gELQApl"; + + private final AnalyticsEventActions eventActions = new AnalyticsEventActions(); + + private final AnalyticsEnrollmentsActions enrollmentActions = new AnalyticsEnrollmentsActions(); + + @Test + public void eventQueryRejectsRegistrationOuNameSortWithoutRegistrationOuDimension() { + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("dimension=pe:2022,ou:ImspTQPwCqd") + .add("headers=ouname") + .add("desc=registrationouname") + .add("displayProperty=NAME") + .add("totalPages=false"); + + ApiResponse response = eventActions.query().get(PROGRAM, JSON, JSON, params); + + validateSortRejected(response, "registrationouname"); + } + + @Test + public void eventQueryRejectsRegistrationOuSortWithoutRegistrationOuDimension() { + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("dimension=pe:2022,ou:ImspTQPwCqd") + .add("headers=ouname") + .add("asc=registrationou") + .add("displayProperty=NAME") + .add("totalPages=false"); + + ApiResponse response = eventActions.query().get(PROGRAM, JSON, JSON, params); + + validateSortRejected(response, "registrationou"); + } + + @Test + public void eventQueryRejectsRegistrationOuNameSortWithFilterOnly() { + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("dimension=pe:2022") + .add("filter=REGISTRATION_OU:" + KAILAHUN) + .add("headers=ouname") + .add("asc=registrationouname") + .add("displayProperty=NAME") + .add("totalPages=false"); + + ApiResponse response = eventActions.query().get(PROGRAM, JSON, JSON, params); + + validateSortRejected(response, "registrationouname"); + } + + @Test + public void enrollmentQueryRejectsRegistrationOuNameSortWithoutRegistrationOuDimension() { + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("dimension=pe:2022,ou:ImspTQPwCqd") + .add("headers=ouname") + .add("desc=registrationouname") + .add("displayProperty=NAME") + .add("totalPages=false"); + + ApiResponse response = enrollmentActions.query().get(PROGRAM, JSON, JSON, params); + + validateSortRejected(response, "registrationouname"); + } + + @Test + public void enrollmentQueryRejectsRegistrationOuNameSortWithFilterOnly() { + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("dimension=pe:2022") + .add("filter=REGISTRATION_OU:" + KAILAHUN) + .add("headers=ouname") + .add("asc=registrationouname") + .add("displayProperty=NAME") + .add("totalPages=false"); + + ApiResponse response = enrollmentActions.query().get(PROGRAM, JSON, JSON, params); + + validateSortRejected(response, "registrationouname"); + } + + @Test + public void eventAggregateRejectsRepeatedRegistrationOuDimension() { + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("dimension=pe:2022") + .add("dimension=REGISTRATION_OU:" + BO) + .add("dimension=REGISTRATION_OU:" + BOMBALI) + .add("displayProperty=NAME") + .add("totalPages=false"); + + ApiResponse response = eventActions.aggregate().get(PROGRAM, JSON, JSON, params); + + validateDuplicateRejected(response); + } + + @Test + public void enrollmentAggregateRejectsRepeatedRegistrationOuDimension() { + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("dimension=pe:2022") + .add("dimension=REGISTRATION_OU:" + BO) + .add("dimension=REGISTRATION_OU:" + BOMBALI) + .add("displayProperty=NAME") + .add("totalPages=false"); + + ApiResponse response = enrollmentActions.aggregate().get(PROGRAM, JSON, JSON, params); + + validateDuplicateRejected(response); + } + + /** Several org units in one dimension is a single occurrence and must still be accepted. */ + @Test + public void eventAggregateAcceptsSeveralOrgUnitsInOneRegistrationOuDimension() { + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("dimension=pe:2022,REGISTRATION_OU:" + BO + ";" + BOMBALI) + .add("displayProperty=NAME") + .add("totalPages=false"); + + ApiResponse response = eventActions.aggregate().get(PROGRAM, JSON, JSON, params); + + response.validate().statusCode(200); + } + + private void validateSortRejected(ApiResponse response, String item) { + response + .validate() + .statusCode(409) + .body("status", equalTo("ERROR")) + .body("errorCode", equalTo("E7261")) + .body( + "message", + equalTo("Sorting by `" + item + "` requires the `REGISTRATION_OU` dimension")); + } + + private void validateDuplicateRejected(ApiResponse response) { + response + .validate() + .statusCode(409) + .body("status", equalTo("ERROR")) + .body("errorCode", equalTo("E7201")) + .body( + "message", equalTo("Dimensions cannot be specified more than once: `REGISTRATION_OU`")); + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery8AutoTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery8AutoTest.java new file mode 100644 index 000000000000..49d9567e6895 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery8AutoTest.java @@ -0,0 +1,343 @@ +/* + * 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.event.query; + +import static org.hisp.dhis.analytics.ValidationHelper.validateHeaderPropertiesByName; +import static org.hisp.dhis.analytics.ValidationHelper.validateResponseStructure; +import static org.hisp.dhis.analytics.ValidationHelper.validateRowValueByName; +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.hisp.dhis.AnalyticsApiTest; +import org.hisp.dhis.test.e2e.actions.analytics.AnalyticsEventActions; +import org.hisp.dhis.test.e2e.dto.ApiResponse; +import org.hisp.dhis.test.e2e.helpers.QueryParamsBuilder; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +/** Groups e2e tests for "/events/query" endpoint. */ +public class EventsQuery8AutoTest extends AnalyticsApiTest { + private final AnalyticsEventActions actions = new AnalyticsEventActions(); + + @Test + public void registrationOuWithBothHeadersAndEventOuName() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("headers=registrationou,registrationouname,ouname") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("pageSize=10") + .add("page=1") + .add("dimension=pe:2022,REGISTRATION_OU:O6uvpzGd5pu"); + + // When + ApiResponse response = actions.query().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 1, + 3, + 3); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":10,\"isLastPage\":true},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"pe\":{},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationouname", + "Registration org unit name", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "ouname", + "Organisation unit name", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row values by name at specific indices (sorted results). + // Validate selected values for row index 0 + validateRowValueByName(response, actualHeaders, 0, "registrationou", "aBfyTU5Wgds"); + validateRowValueByName(response, actualHeaders, 0, "ouname", "Sienga CHP"); + } + + @Test + public void registrationOuWithLevel2SortedAscending() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("asc=registrationouname") + .add("headers=registrationou,registrationouname") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("pageSize=10") + .add("page=1") + .add("dimension=pe:2022,REGISTRATION_OU:LEVEL-2"); + + // When + ApiResponse response = actions.query().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 6, + 2, + 2); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":10,\"isLastPage\":true},\"items\":{\"jUb8gELQApl\":{\"name\":\"Kailahun\"},\"TEQlaapDQoK\":{\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"name\":\"Tonkolili\"},\"Vth0fbpFcsO\":{\"name\":\"Kono\"},\"ou\":{},\"PMa2VCrupOd\":{\"name\":\"Kambia\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"bL4ooGhyHRQ\":{\"name\":\"Pujehun\"},\"kJq2mPyFEHo\":{\"name\":\"Kenema\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"},\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"at6UHUQatSo\":{\"name\":\"Western Area\"},\"pe\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"lc3eMKXaEfw\":{\"name\":\"Bonthe\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"},\"qhqAxPSTUXp\":{\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"name\":\"Moyamba\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationou", + "Registration org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationouname", + "Registration org unit name", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row values by name at specific indices (sorted results). + // Validate selected values for row index 0 + validateRowValueByName(response, actualHeaders, 0, "registrationou", "aBfyTU5Wgds"); + validateRowValueByName(response, actualHeaders, 0, "registrationouname", "Nduvuibu MCHP"); + + // Validate selected values for row index 2 + validateRowValueByName(response, actualHeaders, 2, "registrationou", "a1E6QWBTEwX"); + validateRowValueByName(response, actualHeaders, 2, "registrationouname", "Sienga CHP"); + + // Validate selected values for row index 4 + validateRowValueByName(response, actualHeaders, 4, "registrationou", "agEKP19IUKI"); + validateRowValueByName(response, actualHeaders, 4, "registrationouname", "Tambiama CHC"); + + // Validate selected values for row index 5 + validateRowValueByName(response, actualHeaders, 5, "registrationou", "agEKP19IUKI"); + validateRowValueByName(response, actualHeaders, 5, "registrationouname", "Tambiama CHC"); + } + + @Test + public void registrationOuAsFilter() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("filter=REGISTRATION_OU:jUb8gELQApl") + .add("headers=ouname") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("pageSize=10") + .add("page=1") + .add("dimension=pe:2022"); + + // When + ApiResponse response = actions.query().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 3, + 1, + 1); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":10,\"isLastPage\":true},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"pe\":{},\"ou\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"2022\":{\"name\":\"2022\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "ouname", + "Organisation unit name", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row values by name at specific indices (sorted results). + // Validate selected values for row index 0 + validateRowValueByName(response, actualHeaders, 0, "ouname", "Tambiama CHC"); + + // Validate selected values for row index 2 + validateRowValueByName(response, actualHeaders, 2, "ouname", "Tambiama CHC"); + } + + @Test + public void registrationOuSortedDescending() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("headers=registrationouname") + .add("displayProperty=NAME") + .add("totalPages=false") + .add("pageSize=10") + .add("page=1") + .add("dimension=pe:2022,REGISTRATION_OU:LEVEL-2") + .add("desc=registrationouname"); + + // When + ApiResponse response = actions.query().get("regOuProg01", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 6, + 1, + 1); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"pageSize\":10,\"isLastPage\":true},\"items\":{\"jUb8gELQApl\":{\"name\":\"Kailahun\"},\"TEQlaapDQoK\":{\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"name\":\"Tonkolili\"},\"Vth0fbpFcsO\":{\"name\":\"Kono\"},\"ou\":{},\"PMa2VCrupOd\":{\"name\":\"Kambia\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"bL4ooGhyHRQ\":{\"name\":\"Pujehun\"},\"kJq2mPyFEHo\":{\"name\":\"Kenema\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"},\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"registrationou\":{\"name\":\"Registration org unit\"},\"at6UHUQatSo\":{\"name\":\"Western Area\"},\"pe\":{},\"regOuProg01\":{\"name\":\"Registration OU test program\"},\"lc3eMKXaEfw\":{\"name\":\"Bonthe\"},\"regOuStge01\":{\"name\":\"Registration OU test stage\"},\"qhqAxPSTUXp\":{\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"name\":\"Moyamba\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"registrationou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "registrationouname", + "Registration org unit name", + "TEXT", + "java.lang.String", + false, + true); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row values by name at specific indices (sorted results). + // Validate selected values for row index 0 + validateRowValueByName(response, actualHeaders, 0, "registrationouname", "Tambiama CHC"); + + // Validate selected values for row index 2 + validateRowValueByName(response, actualHeaders, 2, "registrationouname", "Sienga CHP"); + + // Validate selected values for row index 4 + validateRowValueByName(response, actualHeaders, 4, "registrationouname", "Sienga CHP"); + + // Validate selected values for row index 5 + validateRowValueByName(response, actualHeaders, 5, "registrationouname", "Nduvuibu MCHP"); + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/enroll-aggregated.json b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/enroll-aggregated.json index d153261838ac..6ddba403e202 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/enroll-aggregated.json +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/enroll-aggregated.json @@ -174,6 +174,41 @@ "version": { "min": 41 } + }, + { + "name": "registrationOuWithLevel2", + "query": "/api/analytics/enrollments/aggregate/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:LEVEL-2&sortOrder=asc&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuWithMultipleOus", + "query": "/api/analytics/enrollments/aggregate/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:O6uvpzGd5pu;fdc6uOvgoji&sortOrder=asc&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuAsFilter", + "query": "/api/analytics/enrollments/aggregate/regOuProg01.json?dimension=pe:2022&filter=REGISTRATION_OU:jUb8gELQApl&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuWithUserOrgUnit", + "query": "/api/analytics/enrollments/aggregate/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:USER_ORGUNIT&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuCombinedWithEnrollmentOu", + "query": "/api/analytics/enrollments/aggregate/regOuProg01.json?dimension=pe:2022,ou:LEVEL-2,REGISTRATION_OU:O6uvpzGd5pu&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } } ] } diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/enroll-query.json b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/enroll-query.json index ae0fb40000a7..2bd04947102d 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/enroll-query.json +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/enroll-query.json @@ -202,6 +202,34 @@ "version": { "min": 41 } + }, + { + "name": "registrationOuWithBothHeadersAndEnrollmentOuName", + "query": "/api/analytics/enrollments/query/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:O6uvpzGd5pu&headers=registrationou,registrationouname,ouname&displayProperty=NAME&pageSize=10&page=1&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuWithLevel2SortedAscending", + "query": "/api/analytics/enrollments/query/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:LEVEL-2&headers=registrationou,registrationouname&asc=registrationouname&displayProperty=NAME&pageSize=10&page=1&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuAsFilter", + "query": "/api/analytics/enrollments/query/regOuProg01.json?dimension=pe:2022&filter=REGISTRATION_OU:jUb8gELQApl&headers=ouname&displayProperty=NAME&pageSize=10&page=1&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuSortedDescending", + "query": "/api/analytics/enrollments/query/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:LEVEL-2&headers=registrationouname&desc=registrationouname&displayProperty=NAME&pageSize=10&page=1&totalPages=false", + "version": { + "min": 44 + } } ] } diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-aggregated.json b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-aggregated.json index 6a8a56bcf432..34f477c12479 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-aggregated.json +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-aggregated.json @@ -244,6 +244,41 @@ "version": { "min": 41 } + }, + { + "name": "registrationOuWithLevel2", + "query": "/api/analytics/events/aggregate/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:LEVEL-2&sortOrder=asc&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuWithMultipleOus", + "query": "/api/analytics/events/aggregate/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:O6uvpzGd5pu;fdc6uOvgoji&sortOrder=asc&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuAsFilter", + "query": "/api/analytics/events/aggregate/regOuProg01.json?dimension=pe:2022&filter=REGISTRATION_OU:jUb8gELQApl&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuWithUserOrgUnit", + "query": "/api/analytics/events/aggregate/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:USER_ORGUNIT&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuCombinedWithEventOu", + "query": "/api/analytics/events/aggregate/regOuProg01.json?dimension=pe:2022,ou:LEVEL-2,REGISTRATION_OU:O6uvpzGd5pu&displayProperty=NAME&totalPages=false", + "version": { + "min": 44 + } } ] } diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-query.json b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-query.json index 2a8bc5805438..2a696e1b38e6 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-query.json +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-query.json @@ -350,6 +350,34 @@ "min": 41 } }, + { + "name": "registrationOuWithBothHeadersAndEventOuName", + "query": "/api/analytics/events/query/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:O6uvpzGd5pu&headers=registrationou,registrationouname,ouname&displayProperty=NAME&pageSize=10&page=1&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuWithLevel2SortedAscending", + "query": "/api/analytics/events/query/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:LEVEL-2&headers=registrationou,registrationouname&asc=registrationouname&displayProperty=NAME&pageSize=10&page=1&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuAsFilter", + "query": "/api/analytics/events/query/regOuProg01.json?dimension=pe:2022&filter=REGISTRATION_OU:jUb8gELQApl&headers=ouname&displayProperty=NAME&pageSize=10&page=1&totalPages=false", + "version": { + "min": 44 + } + }, + { + "name": "registrationOuSortedDescending", + "query": "/api/analytics/events/query/regOuProg01.json?dimension=pe:2022,REGISTRATION_OU:LEVEL-2&headers=registrationouname&desc=registrationouname&displayProperty=NAME&pageSize=10&page=1&totalPages=false", + "version": { + "min": 44 + } + }, { "name": "eventsSortedByEnrollmentOuNameAscending", "query": "/api/analytics/events/query/ur1Edk5Oe2n.json?dimension=pe:2022,ENROLLMENT_OU:Gtnbmf4LkOz;DiszpKrYNg8&headers=enrollmentouname,ouname&displayProperty=NAME&totalPages=false&pageSize=10&page=1&asc=enrollmentouname", diff --git a/dhis-2/dhis-test-e2e/src/test/resources/db/seed/010_registration_ou.sql b/dhis-2/dhis-test-e2e/src/test/resources/db/seed/010_registration_ou.sql new file mode 100644 index 000000000000..67c65cb14ec2 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/resources/db/seed/010_registration_ou.sql @@ -0,0 +1,230 @@ +-- Fixture for REGISTRATION_OU analytics coverage (DHIS2-21980). +-- Creates a dedicated tracker program whose tracked entities are registered, enrolled and served in +-- three different districts, so registration OU, enrollment OU and event OU can never be confused. +-- +-- Why a dedicated program: analytics event and enrollment requests are program-scoped, so a new +-- program gets its own analytics_event_/analytics_enrollment_ tables and cannot perturb the exact +-- counts the existing suites assert on. Nothing here mutates a pre-existing row. +-- +-- Every row is CLONED from an existing demo row and then overridden, rather than built column by +-- column. Two reasons. Several columns are nullable in the schema but map to Java primitives (for +-- example Program.completeEventsExpiryDays), so a hand-built row loads fine in SQL and then fails +-- to hydrate. And cloning tracks whatever shape the dump actually has, so this survives the +-- difference between the 2.39.6 e2e dump and a current schema. +-- +-- Latin square. Counts per district differ for every dimension, so reading the wrong column +-- produces a wrong NUMBER rather than merely a wrong label: +-- +-- tracked entities registration OU enrollment OU event OU (1 event each) +-- T1 Bo Bombali Kailahun +-- T2, T3 Bombali Kailahun Bo +-- T4, T5, T6 Kailahun Bo Bombali +-- +-- events/aggregate REGISTRATION_OU Bo=1 Bombali=2 Kailahun=3 +-- ou (event OU) Bo=2 Bombali=3 Kailahun=1 +-- enrollments/aggregate REGISTRATION_OU Bo=1 Bombali=2 Kailahun=3 +-- ou (enrolment) Bo=3 Bombali=1 Kailahun=2 +-- +-- Org units are reused from the Sierra Leone demo, so the hierarchy and +-- analytics_rs_orgunitstructure already resolve them: +-- Bo O6uvpzGd5pu -> Nduvuibu MCHP aBfyTU5Wgds +-- Bombali fdc6uOvgoji -> Tambiama CHC agEKP19IUKI +-- Kailahun jUb8gELQApl -> Sienga CHP a1E6QWBTEwX +-- +-- ETL constraints this fixture must satisfy (JdbcEventAnalyticsTableManager / +-- JdbcEnrollmentAnalyticsTableManager): +-- * ev.lastupdated < export start time, and en.lastupdated <= export start time -- hence the fixed +-- 2022 timestamps rather than now(), which would race the export that runs seconds later. +-- * event occurreddate not null, and enrollment occurreddate not null -- an enrollment without an +-- occurreddate never reaches analytics_enrollment_*. +-- * event status in (COMPLETED, ACTIVE, SCHEDULE); deleted false on event, enrollment and entity. +-- * dates must exist in analytics_rs_dateperiodstructure -- the demo dump spans 1975..2051. +-- +-- Surrogate ids come from a 9,1xx,xxx block, far above the dump's maxima, and the sequence is bumped +-- past the block at the end so later API-created objects cannot collide. The whole file is guarded +-- on the program uid, so re-running it is a no-op. + +-- --------------------------------------------------------------------------- +-- Tracked entity type, cloned from an existing type +-- --------------------------------------------------------------------------- + +create temporary table seed_regou_tet as +select * from trackedentitytype where uid = 'UinS6TQnkUi'; + +update seed_regou_tet +set trackedentitytypeid = 9100001, + uid = 'regOuTetp01', + code = 'REGOU_TET', + name = 'Registration OU test entity', + shortname = 'RegOu TET', + created = timestamp '2022-07-01 00:00:00', + lastupdated = timestamp '2022-07-01 00:00:00'; + +insert into trackedentitytype +select * from seed_regou_tet +where not exists (select 1 from program where uid = 'regOuProg01'); + +-- --------------------------------------------------------------------------- +-- Program, cloned from Child Programme (a WITH_REGISTRATION program) +-- --------------------------------------------------------------------------- + +create temporary table seed_regou_program as +select * from program where uid = 'IpHINAT79UW'; + +update seed_regou_program +set programid = 9100002, + uid = 'regOuProg01', + code = 'REGOU_PRG', + name = 'Registration OU test program', + shortname = 'RegOu program', + description = 'Fixture for REGISTRATION_OU analytics coverage', + trackedentitytypeid = 9100001, + version = 1, + created = timestamp '2022-07-01 00:00:00', + lastupdated = timestamp '2022-07-01 00:00:00'; + +insert into program +select * from seed_regou_program +where not exists (select 1 from program where uid = 'regOuProg01'); + +-- --------------------------------------------------------------------------- +-- Program stage, cloned from the Birth stage +-- --------------------------------------------------------------------------- + +create temporary table seed_regou_stage as +select * from programstage where uid = 'A03MvHHogjR'; + +update seed_regou_stage +set programstageid = 9100003, + uid = 'regOuStge01', + code = 'REGOU_PS', + name = 'Registration OU test stage', + programid = 9100002, + created = timestamp '2022-07-01 00:00:00', + lastupdated = timestamp '2022-07-01 00:00:00'; + +insert into programstage +select * from seed_regou_stage +where not exists (select 1 from programstage where uid = 'regOuStge01'); + +-- The program is offered at the three facilities the fixture uses. +insert into program_organisationunits (programid, organisationunitid) +select 9100002, ou.organisationunitid +from organisationunit ou +where ou.uid in ('aBfyTU5Wgds', 'agEKP19IUKI', 'a1E6QWBTEwX') + and not exists (select 1 + from program_organisationunits po + where po.programid = 9100002 + and po.organisationunitid = ou.organisationunitid); + +-- --------------------------------------------------------------------------- +-- Tracked entities. The registration org unit is trackedentity.organisationunitid. +-- --------------------------------------------------------------------------- + +create temporary table seed_regou_te as +select t.*, v.new_id, v.new_uid, v.ou_uid +from (select * from trackedentity where uid = 'uhubxsfLanV') t +cross join (values (9100011, 'regOuTei001', 'aBfyTU5Wgds'), -- registered in Bo + (9100012, 'regOuTei002', 'agEKP19IUKI'), -- registered in Bombali + (9100013, 'regOuTei003', 'agEKP19IUKI'), + (9100014, 'regOuTei004', 'a1E6QWBTEwX'), -- registered in Kailahun + (9100015, 'regOuTei005', 'a1E6QWBTEwX'), + (9100016, 'regOuTei006', 'a1E6QWBTEwX')) as v(new_id, new_uid, ou_uid); + +update seed_regou_te s +set trackedentityid = s.new_id, + uid = s.new_uid, + code = s.new_uid, + trackedentitytypeid = 9100001, + organisationunitid = (select organisationunitid from organisationunit where uid = s.ou_uid), + deleted = false, + created = timestamp '2022-07-01 00:00:00', + lastupdated = timestamp '2022-07-01 00:00:00'; + +alter table seed_regou_te drop column new_id, drop column new_uid, drop column ou_uid; + +insert into trackedentity +select * from seed_regou_te +where not exists (select 1 from trackedentity where uid = 'regOuTei001'); + +-- --------------------------------------------------------------------------- +-- Enrollments. enrollment.organisationunitid is the enrollment org unit, rotated one district on. +-- --------------------------------------------------------------------------- + +create temporary table seed_regou_enrollment as +select e.*, v.new_id, v.new_uid, v.te_id, v.ou_uid +from (select * from enrollment where uid = 'KxXkjF6buFN') e +cross join (values (9100021, 'regOuEnr001', 9100011, 'agEKP19IUKI'), -- enrolled in Bombali + (9100022, 'regOuEnr002', 9100012, 'a1E6QWBTEwX'), -- enrolled in Kailahun + (9100023, 'regOuEnr003', 9100013, 'a1E6QWBTEwX'), + (9100024, 'regOuEnr004', 9100014, 'aBfyTU5Wgds'), -- enrolled in Bo + (9100025, 'regOuEnr005', 9100015, 'aBfyTU5Wgds'), + (9100026, 'regOuEnr006', 9100016, 'aBfyTU5Wgds')) as v(new_id, new_uid, te_id, ou_uid); + +update seed_regou_enrollment s +set enrollmentid = s.new_id, + uid = s.new_uid, + programid = 9100002, + trackedentityid = s.te_id, + organisationunitid = (select organisationunitid from organisationunit where uid = s.ou_uid), + enrollmentdate = timestamp '2022-03-01 00:00:00', + occurreddate = timestamp '2022-03-01 00:00:00', + status = 'ACTIVE', + deleted = false, + created = timestamp '2022-07-01 00:00:00', + lastupdated = timestamp '2022-07-01 00:00:00'; + +alter table seed_regou_enrollment + drop column new_id, drop column new_uid, drop column te_id, drop column ou_uid; + +insert into enrollment +select * from seed_regou_enrollment +where not exists (select 1 from enrollment where uid = 'regOuEnr001'); + +-- --------------------------------------------------------------------------- +-- Events. trackerevent.organisationunitid is the event org unit, rotated one district further. +-- --------------------------------------------------------------------------- + +create temporary table seed_regou_event as +select e.*, v.new_id, v.new_uid, v.en_id, v.ou_uid +from (select * from trackerevent where uid = 'MyWQlBkftni') e +cross join (values (9100031, 'regOuEvt001', 9100021, 'a1E6QWBTEwX'), -- served in Kailahun + (9100032, 'regOuEvt002', 9100022, 'aBfyTU5Wgds'), -- served in Bo + (9100033, 'regOuEvt003', 9100023, 'aBfyTU5Wgds'), + (9100034, 'regOuEvt004', 9100024, 'agEKP19IUKI'), -- served in Bombali + (9100035, 'regOuEvt005', 9100025, 'agEKP19IUKI'), + (9100036, 'regOuEvt006', 9100026, 'agEKP19IUKI')) as v(new_id, new_uid, en_id, ou_uid); + +update seed_regou_event s +set eventid = s.new_id, + uid = s.new_uid, + enrollmentid = s.en_id, + programstageid = 9100003, + organisationunitid = (select organisationunitid from organisationunit where uid = s.ou_uid), + occurreddate = timestamp '2022-06-15 00:00:00', + status = 'COMPLETED', + deleted = false, + eventdatavalues = '{}'::jsonb, + created = timestamp '2022-07-01 00:00:00', + lastupdated = timestamp '2022-07-01 00:00:00'; + +alter table seed_regou_event + drop column new_id, drop column new_uid, drop column en_id, drop column ou_uid; + +insert into trackerevent +select * from seed_regou_event +where not exists (select 1 from trackerevent where uid = 'regOuEvt001'); + +-- --------------------------------------------------------------------------- +-- Keep the id generator clear of the fixture block, and clean up the templates. +-- --------------------------------------------------------------------------- + +select setval('hibernate_sequence', + greatest((select last_value from hibernate_sequence), 9200000)); + +drop table seed_regou_tet; +drop table seed_regou_program; +drop table seed_regou_stage; +drop table seed_regou_te; +drop table seed_regou_enrollment; +drop table seed_regou_event; diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/EventVisualizationControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/EventVisualizationControllerTest.java index 9f8aaa637a87..e9f41f610552 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/EventVisualizationControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/EventVisualizationControllerTest.java @@ -1110,6 +1110,40 @@ void testPostForEnrollmentOu() { assertThat(response.get("rows").toString(), containsString(mockProgram.getUid())); } + @Test + void testPostForRegistrationOu() { + // Given + String registrationOuDimension = "registrationOu"; + String registrationOuBody = + "{'dimension': '" + + registrationOuDimension + + "'," + + "'items': [{'id': 'USER_ORGUNIT'}]," + + "'program': {'id':'" + + mockProgram.getUid() + + "'}}"; + + String body = + "{'name': 'Name Test', 'type': 'STACKED_COLUMN', 'program': {'id':'" + + mockProgram.getUid() + + "'}, 'rows': [" + + registrationOuBody + + "]}"; + + // When + String uid = assertStatus(CREATED, POST("/eventVisualizations/", body)); + + // Then + JsonObject response = + GET("/eventVisualizations/" + uid + "?fields=*,rows[:all,items[:all],program[id]]") + .content(); + + assertThat(response.get("simpleDimensions").toString(), containsString("ROW")); + assertThat(response.get("simpleDimensions").toString(), containsString("USER_ORGUNIT")); + assertThat(response.get("rows").toString(), containsString(registrationOuDimension)); + assertThat(response.get("rows").toString(), containsString(mockProgram.getUid())); + } + @Test void testManyPeriodsCanBeSavedIntoEventVisualization() { // Setup test data