diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/analytics/table/EventAnalyticsColumnName.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/analytics/table/EventAnalyticsColumnName.java index c0e0bd33065b..6ba40a432481 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/analytics/table/EventAnalyticsColumnName.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/analytics/table/EventAnalyticsColumnName.java @@ -69,5 +69,6 @@ public final class EventAnalyticsColumnName { public static final String ENROLLMENT_GEOMETRY_COLUMN_NAME = "enrollmentgeometry"; public static final String REGISTRATION_OU_COLUMN_NAME = "registrationou"; public static final String ENROLLMENT_OU_COLUMN_NAME = "enrollmentou"; + public static final String ENROLLMENT_OU_NAME_COLUMN_NAME = "enrollmentouname"; public static final String TRACKED_ENTITY_GEOMETRY_COLUMN_NAME = "tegeometry"; } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/util/PeriodCriteriaUtils.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/util/PeriodCriteriaUtils.java index bea5904e2a83..0f05580dcf46 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/util/PeriodCriteriaUtils.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/util/PeriodCriteriaUtils.java @@ -32,8 +32,8 @@ import static lombok.AccessLevel.PRIVATE; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.hisp.dhis.common.DimensionConstants.PERIOD_DIM_ID; -import static org.hisp.dhis.common.DimensionConstants.STATIC_DATE_DIMENSIONS; +import java.util.Set; import java.util.stream.Stream; import lombok.NoArgsConstructor; import org.hisp.dhis.common.EnrollmentAnalyticsQueryCriteria; @@ -43,6 +43,20 @@ /** Helper class that provides supportive methods to deal with query criteria and periods. */ @NoArgsConstructor(access = PRIVATE) public class PeriodCriteriaUtils { + + /** + * Date dimensions that get normalized to "pe" dimensions during request processing. When used as + * dimension/filter values (e.g., "ENROLLMENT_DATE:2021"), they represent period constraints and + * should prevent a default period from being added. + */ + private static final Set STATIC_DATE_DIMENSIONS = + Set.of("ENROLLMENT_DATE", "INCIDENT_DATE", "LAST_UPDATED", "CREATED_DATE", "COMPLETED_DATE"); + + private static boolean hasStaticDateDimension(Set dims) { + return dims.stream() + .anyMatch(d -> STATIC_DATE_DIMENSIONS.stream().anyMatch(sd -> d.startsWith(sd + ":"))); + } + /** * Add a default period for the given criteria, if none is present. * @@ -83,6 +97,8 @@ public static boolean hasPeriod(EventsAnalyticsQueryCriteria criteria) { || criteria.getFilter().stream().anyMatch(d -> d.contains(".EVENT_DATE:")) || criteria.getDimension().stream().anyMatch(d -> d.contains(".SCHEDULED_DATE:")) || criteria.getFilter().stream().anyMatch(d -> d.contains(".SCHEDULED_DATE:")) + || hasStaticDateDimension(criteria.getDimension()) + || hasStaticDateDimension(criteria.getFilter()) || !isBlank(criteria.getEventDate()) || !isBlank(criteria.getOccurredDate()) || !isBlank(criteria.getEnrollmentDate()) @@ -106,6 +122,8 @@ public static boolean hasPeriod(EventsAnalyticsQueryCriteria criteria) { public static boolean hasPeriod(EnrollmentAnalyticsQueryCriteria criteria) { return criteria.getDimension().stream().anyMatch(d -> d.startsWith(PERIOD_DIM_ID)) || (criteria.getFilter().stream().anyMatch(d -> d.startsWith(PERIOD_DIM_ID))) + || hasStaticDateDimension(criteria.getDimension()) + || hasStaticDateDimension(criteria.getFilter()) || !isBlank(criteria.getEnrollmentDate()) || (criteria.getStartDate() != null && criteria.getEndDate() != null) || !isBlank(criteria.getIncidentDate()) diff --git a/dhis-2/dhis-api/src/test/java/org/hisp/dhis/util/PeriodCriteriaUtilsTest.java b/dhis-2/dhis-api/src/test/java/org/hisp/dhis/util/PeriodCriteriaUtilsTest.java index 65cd59e8b253..f6113549bed2 100644 --- a/dhis-2/dhis-api/src/test/java/org/hisp/dhis/util/PeriodCriteriaUtilsTest.java +++ b/dhis-2/dhis-api/src/test/java/org/hisp/dhis/util/PeriodCriteriaUtilsTest.java @@ -302,6 +302,43 @@ void testHasPeriodEnrollment_false_whenNoPeriodInformationPresent() { assertFalse(PeriodCriteriaUtils.hasPeriod(c)); } + @Test + void testHasPeriodEvent_whenDimensionContainsEnrollmentDate() { + EventsAnalyticsQueryCriteria c = getDefaultEventsAnalyticsQueryCriteria(); + c.getDimension().add("ENROLLMENT_DATE:2021"); + assertTrue(PeriodCriteriaUtils.hasPeriod(c)); + } + + @Test + void testHasPeriodEvent_whenDimensionContainsIncidentDate() { + EventsAnalyticsQueryCriteria c = getDefaultEventsAnalyticsQueryCriteria(); + c.getDimension().add("INCIDENT_DATE:THIS_YEAR"); + assertTrue(PeriodCriteriaUtils.hasPeriod(c)); + } + + @Test + void testHasPeriodEvent_whenFilterContainsEnrollmentDate() { + EventsAnalyticsQueryCriteria c = getDefaultEventsAnalyticsQueryCriteria(); + Set filters = new HashSet<>(); + filters.add("ENROLLMENT_DATE:2021"); + c.setFilter(filters); + assertTrue(PeriodCriteriaUtils.hasPeriod(c)); + } + + @Test + void testHasPeriodEnrollment_whenDimensionContainsEnrollmentDateDim() { + EnrollmentAnalyticsQueryCriteria c = getDefaultEnrollmentsAnalyticsQueryCriteria(); + c.getDimension().add("ENROLLMENT_DATE:2021"); + assertTrue(PeriodCriteriaUtils.hasPeriod(c)); + } + + @Test + void testHasPeriodEnrollment_whenDimensionContainsIncidentDateDim() { + EnrollmentAnalyticsQueryCriteria c = getDefaultEnrollmentsAnalyticsQueryCriteria(); + c.getDimension().add("INCIDENT_DATE:LAST_12_MONTHS"); + assertTrue(PeriodCriteriaUtils.hasPeriod(c)); + } + @Test void testHasPeriodEvent_whenDimensionContainsStageDotEventDate() { EventsAnalyticsQueryCriteria c = getDefaultEventsAnalyticsQueryCriteria(); 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 0e03e46696e0..1ddfb536098e 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 @@ -266,6 +266,18 @@ public class EventQueryParams extends DataQueryParams { @Getter protected List userOrgUnits = new ArrayList<>(); + /** Items when ENROLLMENT_OU is used as a dimension. */ + private List enrollmentOuDimensionItems = new ArrayList<>(); + + /** Items when ENROLLMENT_OU is used as a filter. */ + private List enrollmentOuFilterItems = new ArrayList<>(); + + /** Level constraints when ENROLLMENT_OU is used as a dimension. */ + private Set enrollmentOuDimensionLevels = new LinkedHashSet<>(); + + /** Level constraints when ENROLLMENT_OU is used as a filter. */ + private Set enrollmentOuFilterLevels = new LinkedHashSet<>(); + // ------------------------------------------------------------------------- // Constructors // ------------------------------------------------------------------------- @@ -341,6 +353,10 @@ protected EventQueryParams instance() { params.userOrgUnits = this.userOrgUnits; params.outputFormat = this.outputFormat; params.piDisagInfo = this.piDisagInfo; + params.enrollmentOuDimensionItems = new ArrayList<>(this.enrollmentOuDimensionItems); + params.enrollmentOuFilterItems = new ArrayList<>(this.enrollmentOuFilterItems); + params.enrollmentOuDimensionLevels = new LinkedHashSet<>(this.enrollmentOuDimensionLevels); + params.enrollmentOuFilterLevels = new LinkedHashSet<>(this.enrollmentOuFilterLevels); return params; } @@ -1249,6 +1265,53 @@ public boolean hasDataIdScheme() { return dataIdScheme != null; } + public boolean hasEnrollmentOuDimension() { + return isNotEmpty(enrollmentOuDimensionItems) || !enrollmentOuDimensionLevels.isEmpty(); + } + + public boolean hasEnrollmentOuFilter() { + return isNotEmpty(enrollmentOuFilterItems) || !enrollmentOuFilterLevels.isEmpty(); + } + + public boolean hasEnrollmentOu() { + return hasEnrollmentOuDimension() || hasEnrollmentOuFilter(); + } + + public List getEnrollmentOuDimensionItems() { + return enrollmentOuDimensionItems; + } + + public List getEnrollmentOuFilterItems() { + return enrollmentOuFilterItems; + } + + /** Returns all enrollment OU items from both dimension and filter. */ + public List getAllEnrollmentOuItems() { + return ListUtils.union(enrollmentOuDimensionItems, enrollmentOuFilterItems); + } + + public Set getEnrollmentOuDimensionLevels() { + return enrollmentOuDimensionLevels; + } + + public Set getEnrollmentOuFilterLevels() { + return enrollmentOuFilterLevels; + } + + public boolean hasEnrollmentOuLevelConstraint() { + return !enrollmentOuDimensionLevels.isEmpty() || !enrollmentOuFilterLevels.isEmpty(); + } + + public Set getAllEnrollmentOuLevelsForSql() { + Set levels = new LinkedHashSet<>(enrollmentOuDimensionLevels); + levels.addAll(enrollmentOuFilterLevels); + return levels; + } + + public List getAllEnrollmentOuItemsForSql() { + return getAllEnrollmentOuItems(); + } + /** * Returns a negative integer in case of ascending sort order, a positive in case of descending * sort order and 0 in case of no sort order. @@ -1802,6 +1865,26 @@ public Builder withPiDisagInfo(PiDisagInfo piDisagInfo) { return this; } + public Builder withEnrollmentOuDimension(List items) { + this.params.enrollmentOuDimensionItems = items; + return this; + } + + public Builder withEnrollmentOuFilter(List items) { + this.params.enrollmentOuFilterItems = items; + return this; + } + + public Builder withEnrollmentOuDimensionLevels(Set levels) { + this.params.enrollmentOuDimensionLevels = new LinkedHashSet<>(levels); + return this; + } + + public Builder withEnrollmentOuFilterLevels(Set levels) { + this.params.enrollmentOuFilterLevels = new LinkedHashSet<>(levels); + return this; + } + public void addMeasureCriteria(MeasureFilter filter, Double value) { this.params.measureCriteria.put(filter, value); } 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 fb785b4a7f18..322172b347cd 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 @@ -136,6 +136,7 @@ import org.hisp.dhis.analytics.common.InQueryCteFilter; import org.hisp.dhis.analytics.common.ProgramIndicatorSubqueryBuilder; import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.analytics.event.data.ou.OrgUnitSqlCoordinator; 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; @@ -513,7 +514,7 @@ private List getSelectColumns( EventQueryParams params, boolean isGroupByClause, boolean isAggregated) { List columns = new ArrayList<>(); - addDimensionSelectColumns(columns, params, isGroupByClause); + addDimensionSelectColumns(columns, params, isGroupByClause, isAggregated); addItemSelectColumns(columns, params, isGroupByClause, isAggregated); return columns; @@ -530,7 +531,10 @@ private List getSelectColumns( * group by columns. */ protected void addDimensionSelectColumns( - List columns, EventQueryParams params, boolean isGroupByClause) { + List columns, + EventQueryParams params, + boolean isGroupByClause, + boolean isAggregated) { params .getDimensions() .forEach( @@ -588,6 +592,9 @@ protected void addDimensionSelectColumns( exactly one period, or no periods and a period filter"""); } }); + + OrgUnitSqlCoordinator.addDimensionSelectColumns( + columns, params, isGroupByClause, isAggregated, getAnalyticsType()); } private void addItemSelectColumns( @@ -1802,7 +1809,7 @@ protected List getSelectColumnsWithCTE(EventQueryParams params, CteConte // Add dimension columns only when the analytics query // is for enrollments if (!cteContext.isEventsAnalytics()) { - addDimensionSelectColumns(columns, params, false); + addDimensionSelectColumns(columns, params, false, false); } // Process query items with CTE references 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 bacde7900d25..693de35de66f 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 @@ -39,6 +39,7 @@ import java.util.function.BiFunction; import java.util.function.Function; import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.analytics.event.data.ou.OrgUnitRowAccess; import org.hisp.dhis.common.DimensionalObject; import org.hisp.dhis.common.IdScheme; import org.hisp.dhis.common.QueryItem; @@ -193,6 +194,10 @@ private void addDimensionData() { extractStringValue(dimension.getDimensionName(), dimension.getValueType()); row.add(dimensionValue); } + + if (params.hasEnrollmentOuDimension()) { + row.add(extractStringValue(OrgUnitRowAccess.enrollmentOuResultColumn(), ValueType.TEXT)); + } } /** 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 09103bf26ee7..c0bf752e382b 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 @@ -91,6 +91,7 @@ import org.hisp.dhis.feedback.ErrorCode; import org.hisp.dhis.feedback.ErrorMessage; import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.organisationunit.OrganisationUnitService; import org.hisp.dhis.program.Program; import org.hisp.dhis.program.ProgramService; import org.hisp.dhis.program.ProgramStage; @@ -107,6 +108,9 @@ @Service("org.hisp.dhis.analytics.event.EventDataQueryService") @RequiredArgsConstructor public class DefaultEventDataQueryService implements EventDataQueryService { + private static final String ENROLLMENT_OU_DIMENSION = "ENROLLMENT_OU"; + private static final String LEVEL_PREFIX = "LEVEL-"; + private final ProgramService programService; private final ProgramStageService programStageService; @@ -121,6 +125,8 @@ public class DefaultEventDataQueryService implements EventDataQueryService { private final DataQueryService dataQueryService; + private final OrganisationUnitService organisationUnitService; + private final QueryItemFilterHandlerRegistry filterHandlerRegistry; @Override @@ -229,100 +235,6 @@ public EventQueryParams getFromRequest(EventDataQueryRequest request, boolean an return eventQueryParams; } - private void addSortToParams( - EventQueryParams.Builder params, EventDataQueryRequest request, Program pr) { - if (request.getAsc() != null) { - for (String sort : request.getAsc()) { - params.addAscSortItem( - getSortItem(sort, pr, request.getOutputType(), request.getEndpointItem())); - } - } - - if (request.getDesc() != null) { - for (String sort : request.getDesc()) { - params.addDescSortItem( - getSortItem(sort, pr, request.getOutputType(), request.getEndpointItem())); - } - } - } - - private void addFiltersToParams( - EventQueryParams.Builder params, - EventDataQueryRequest request, - List userOrgUnits, - Program pr, - IdScheme idScheme) { - if (request.getFilter() != null) { - for (Set filterGroup : request.getFilter()) { - UUID groupUUID = UUID.randomUUID(); - for (String dim : filterGroup) { - String dimensionId = getDimensionFromParam(dim); - List items = getDimensionItemsFromParam(dim); - validateStaticDateDimensionSupport(dimensionId, dim, request); - DimensionAndItems normalized = normalizeStaticDateDimension(dimensionId, items); - dimensionId = normalized.dimension(); - items = normalized.items(); - - GroupableItem groupableItem = - dataQueryService.getDimension( - dimensionId, - items, - request.getRelativePeriodDate(), - userOrgUnits, - true, - null, - idScheme); - - if (groupableItem != null) { - params.addFilter((DimensionalObject) groupableItem); - } else { - groupableItem = - getQueryItem(dim, pr, request.getOutputType(), request.getRelativePeriodDate()); - params.addItemFilter((QueryItem) groupableItem); - } - - groupableItem.setGroupUUID(groupUUID); - } - } - } - } - - private void addDimensionsToParams( - EventQueryParams.Builder params, - EventDataQueryRequest request, - List userOrgUnits, - Program pr, - IdScheme idScheme) { - if (request.getDimension() != null) { - for (Set dimensionGroup : request.getDimension()) { - UUID groupUUID = UUID.randomUUID(); - - for (String dim : dimensionGroup) { - String dimensionId = getDimensionFromParam(dim); - List items = getDimensionItemsFromParam(dim); - validateStaticDateDimensionSupport(dimensionId, dim, request); - DimensionAndItems normalized = normalizeStaticDateDimension(dimensionId, items); - dimensionId = normalized.dimension(); - items = normalized.items(); - - GroupableItem groupableItem = - dataQueryService.getDimension( - dimensionId, items, request, userOrgUnits, true, idScheme); - - if (groupableItem != null) { - params.addDimension((DimensionalObject) groupableItem); - } else { - groupableItem = - getQueryItem(dim, pr, request.getOutputType(), request.getRelativePeriodDate()); - params.addItem((QueryItem) groupableItem); - } - - groupableItem.setGroupUUID(groupUUID); - } - } - } - } - @Override public EventQueryParams getFromAnalyticalObject(EventAnalyticalObject object) { Assert.notNull(object, "Event analytical object cannot be null"); @@ -465,6 +377,219 @@ public List getCoordinateFields(EventDataQueryRequest request) { return coordinateFields.stream().distinct().collect(Collectors.toList()); } + @Override + public QueryItem getQueryItem(String dimensionString, Program program, EventOutputType type) { + return getQueryItem(dimensionString, program, type, null); + } + + private void addSortToParams( + EventQueryParams.Builder params, EventDataQueryRequest request, Program pr) { + if (request.getAsc() != null) { + for (String sort : request.getAsc()) { + params.addAscSortItem( + getSortItem(sort, pr, request.getOutputType(), request.getEndpointItem())); + } + } + + if (request.getDesc() != null) { + for (String sort : request.getDesc()) { + params.addDescSortItem( + getSortItem(sort, pr, request.getOutputType(), request.getEndpointItem())); + } + } + } + + private void addFiltersToParams( + EventQueryParams.Builder params, + EventDataQueryRequest request, + List userOrgUnits, + Program pr, + IdScheme idScheme) { + if (request.getFilter() != null) { + for (NormalizedDimensionInput input : + normalizeDimensionInputs(request.getFilter(), request)) { + if (ENROLLMENT_OU_DIMENSION.equals(input.dimensionId())) { + resolveEnrollmentOuFilter(params, request, userOrgUnits, input.items(), idScheme); + continue; + } + + GroupableItem groupableItem = + dataQueryService.getDimension( + input.dimensionId(), + input.items(), + request.getRelativePeriodDate(), + userOrgUnits, + true, + null, + idScheme); + + if (groupableItem != null) { + groupableItem.setGroupUUID(input.groupUUID()); + params.addFilter((DimensionalObject) groupableItem); + } else { + groupableItem = + getQueryItem( + input.rawDimension(), + pr, + request.getOutputType(), + request.getRelativePeriodDate()); + params.addItemFilter((QueryItem) groupableItem); + groupableItem.setGroupUUID(input.groupUUID()); + } + } + } + } + + private void addDimensionsToParams( + EventQueryParams.Builder params, + EventDataQueryRequest request, + List userOrgUnits, + Program pr, + IdScheme idScheme) { + if (request.getDimension() != null) { + for (NormalizedDimensionInput input : + normalizeDimensionInputs(request.getDimension(), request)) { + if (ENROLLMENT_OU_DIMENSION.equals(input.dimensionId())) { + resolveEnrollmentOuDimension(params, request, userOrgUnits, input.items(), idScheme); + continue; + } + + GroupableItem groupableItem = + dataQueryService.getDimension( + input.dimensionId(), input.items(), request, userOrgUnits, true, idScheme); + + if (groupableItem != null) { + groupableItem.setGroupUUID(input.groupUUID()); + params.addDimension((DimensionalObject) groupableItem); + } else { + groupableItem = + getQueryItem( + input.rawDimension(), + pr, + request.getOutputType(), + request.getRelativePeriodDate()); + params.addItem((QueryItem) groupableItem); + groupableItem.setGroupUUID(input.groupUUID()); + } + } + } + } + + /** + * Helper class to track and merge period (pe) dimensions. State management for merging `pe` + * parameters is isolated here to reduce cognitive complexity. + */ + private static class PeriodDimensionTracker { + private final List mergedItems = new ArrayList<>(); + private int firstIndex = -1; + private UUID firstGroupUUID = null; + + /** + * Tracks a period dimension item for later merging. + * + * @param currentIndex the current index in the normalized inputs list + * @param groupUUID the group UUID associated with the item + * @param items the list of items to track + */ + public void track(int currentIndex, UUID groupUUID, List items) { + if (firstIndex < 0) { + firstIndex = currentIndex; + firstGroupUUID = groupUUID; + } + mergedItems.addAll(items); + } + + /** + * Inserts the merged period items into the normalized inputs list at the appropriate position. + * + * @param inputs the list of normalized inputs + */ + private void insertMergedInto(List inputs) { + if (mergedItems.isEmpty()) { + return; + } + List distinctItems = mergedItems.stream().distinct().toList(); + String mergedRawDimension = "pe:" + String.join(";", distinctItems); + + NormalizedDimensionInput mergedInput = + new NormalizedDimensionInput(mergedRawDimension, "pe", distinctItems, firstGroupUUID); + + if (firstIndex >= 0 && firstIndex <= inputs.size()) { + inputs.add(firstIndex, mergedInput); + } else { + inputs.add(mergedInput); + } + } + } + + /** + * Processes a single dimension and adds it to the list of normalized inputs or tracks it if it's + * a period dimension. + * + * @param rawDimension the raw dimension string + * @param groupUUID the group UUID + * @param request the original data query request + * @param normalizedInputs the list of normalized inputs to populate + * @param peTracker the period dimension tracker + */ + private void processDimension( + String rawDimension, + UUID groupUUID, + EventDataQueryRequest request, + List normalizedInputs, + PeriodDimensionTracker peTracker) { + + String dimensionId = getDimensionFromParam(rawDimension); + List items = getDimensionItemsFromParam(rawDimension); + + if (ENROLLMENT_OU_DIMENSION.equals(dimensionId)) { + normalizedInputs.add( + new NormalizedDimensionInput(rawDimension, dimensionId, items, groupUUID)); + return; + } + + validateStaticDateDimensionSupport(dimensionId, rawDimension, request); + DimensionAndItems normalized = normalizeStaticDateDimension(dimensionId, items); + + if ("pe".equals(normalized.dimension())) { + peTracker.track(normalizedInputs.size(), groupUUID, normalized.items()); + return; + } + + normalizedInputs.add( + new NormalizedDimensionInput( + rawDimension, normalized.dimension(), normalized.items(), groupUUID)); + } + + /** + * Normalizes the dimension inputs by separating and processing them, merging period dimensions as + * needed. + * + * @param requestDimensions the raw request dimensions + * @param request the original data query request + * @return the list of normalized dimension inputs + */ + private List normalizeDimensionInputs( + Set> requestDimensions, EventDataQueryRequest request) { + + List normalizedInputs = new ArrayList<>(); + PeriodDimensionTracker peTracker = new PeriodDimensionTracker(); + + for (Set dimensionGroup : requestDimensions) { + UUID groupUUID = UUID.randomUUID(); + + for (String rawDimension : dimensionGroup) { + processDimension(rawDimension, groupUUID, request, normalizedInputs, peTracker); + } + } + + peTracker.insertMergedInto(normalizedInputs); + return normalizedInputs; + } + + private record NormalizedDimensionInput( + String rawDimension, String dimensionId, List items, UUID groupUUID) {} + // ------------------------------------------------------------------------- // Supportive methods // ------------------------------------------------------------------------- @@ -505,11 +630,6 @@ private QueryItem getQueryItem( return getQueryItem(dimension, program, type, relativePeriodDate); } - @Override - public QueryItem getQueryItem(String dimensionString, Program program, EventOutputType type) { - return getQueryItem(dimensionString, program, type, null); - } - private QueryItem getQueryItem( String dimensionString, Program program, EventOutputType type, Date relativePeriodDate) { String[] split = dimensionString.split(DIMENSION_NAME_SEP); @@ -569,6 +689,9 @@ private DimensionalItemObject getValueDimension(String value) { throw new IllegalQueryException(new ErrorMessage(ErrorCode.E7223, value)); } + private static final Set DATE_COMPARISON_OPERATORS = + Set.of("GT", "GE", "LT", "LE", "EQ", "NE"); + private DimensionAndItems normalizeStaticDateDimension(String dimensionId, List items) { if (dimensionId == null || items == null || items.isEmpty()) { return new DimensionAndItems(dimensionId, items); @@ -578,12 +701,24 @@ private DimensionAndItems normalizeStaticDateDimension(String dimensionId, List< return new DimensionAndItems(dimensionId, items); } + // Operator-based items (e.g., GT:2023-01-01) bypass period normalization + // and are handled by DateFilterHandler via the QueryItem path + if (hasDateOperatorPrefix(items)) { + return new DimensionAndItems(dimensionId, items); + } + List periodItems = items.stream().map(item -> item + DIMENSION_NAME_SEP + dimensionId).distinct().toList(); return new DimensionAndItems("pe", periodItems); } + private static boolean hasDateOperatorPrefix(List items) { + String first = items.get(0); + int colonIndex = first.indexOf(':'); + return colonIndex > 0 && DATE_COMPARISON_OPERATORS.contains(first.substring(0, colonIndex)); + } + private void validateStaticDateDimensionSupport( String dimensionId, String dimensionString, EventDataQueryRequest request) { if (!"CREATED_DATE".equals(dimensionId)) { @@ -600,6 +735,97 @@ private boolean isEventAggregateRequest(EventDataQueryRequest request) { && EndpointItem.EVENT.equals(request.getEndpointItem()); } + /** + * Resolves ENROLLMENT_OU items as org units and stores them as enrollment OU dimension items. + * Reuses the standard OU resolution infrastructure by passing "ou" to getDimension(). + */ + private void resolveEnrollmentOuDimension( + EventQueryParams.Builder params, + EventDataQueryRequest request, + List userOrgUnits, + List items, + IdScheme idScheme) { + EnrollmentOuResolution resolution = + resolveEnrollmentOuItems(items, request, userOrgUnits, idScheme, true); + + if (!resolution.uidItems().isEmpty()) { + params.withEnrollmentOuDimension(resolution.uidItems()); + } + + params.withEnrollmentOuDimensionLevels(resolution.levels()); + } + + /** + * Resolves ENROLLMENT_OU items as org units and stores them as enrollment OU filter items. Reuses + * the standard OU resolution infrastructure by passing "ou" to getDimension(). + */ + private void resolveEnrollmentOuFilter( + EventQueryParams.Builder params, + EventDataQueryRequest request, + List userOrgUnits, + List items, + IdScheme idScheme) { + EnrollmentOuResolution resolution = + resolveEnrollmentOuItems(items, request, userOrgUnits, idScheme, false); + + if (!resolution.uidItems().isEmpty()) { + params.withEnrollmentOuFilter(resolution.uidItems()); + } + + params.withEnrollmentOuFilterLevels(resolution.levels()); + } + + private EnrollmentOuResolution resolveEnrollmentOuItems( + List items, + EventDataQueryRequest request, + List userOrgUnits, + IdScheme idScheme, + boolean fromDimension) { + List nonLevelItems = new ArrayList<>(); + Set levels = new java.util.LinkedHashSet<>(); + + for (String item : items) { + if (item != null && item.startsWith(LEVEL_PREFIX)) { + String levelId = substringAfter(item, LEVEL_PREFIX); + Integer level = organisationUnitService.getOrganisationUnitLevelByLevelOrUid(levelId); + if (level != null) { + levels.add(level); + } + } else { + nonLevelItems.add(item); + } + } + + List uidItems = new ArrayList<>(); + + if (!nonLevelItems.isEmpty()) { + GroupableItem ouDimension = + fromDimension + ? dataQueryService.getDimension( + "ou", nonLevelItems, request, userOrgUnits, true, idScheme) + : dataQueryService.getDimension( + "ou", + nonLevelItems, + request.getRelativePeriodDate(), + userOrgUnits, + true, + null, + idScheme); + if (ouDimension != null) { + uidItems.addAll(((DimensionalObject) ouDimension).getItems()); + } + } + + if (uidItems.isEmpty() && levels.isEmpty()) { + throwIllegalQueryEx(ErrorCode.E7143, ENROLLMENT_OU_DIMENSION); + } + + return new EnrollmentOuResolution(uidItems, levels); + } + + private record EnrollmentOuResolution( + List uidItems, Set levels) {} + private record DimensionAndItems(String dimension, List items) {} @Getter 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 d038b8e56a76..d06cef8c0350 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 @@ -98,7 +98,7 @@ public ErrorMessage validateForErrorMessage(EventQueryParams params) { if (params == null) { throw new IllegalQueryException(ErrorCode.E7100); } - if (!params.hasOrganisationUnits()) { + if (!params.hasOrganisationUnits() && !params.hasEnrollmentOu()) { return new ErrorMessage(ErrorCode.E7200); } if (!params.getDuplicateDimensions().isEmpty()) { diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EnrollmentQueryService.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EnrollmentQueryService.java index 22d91c1ceb81..e243cc2f8fd5 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EnrollmentQueryService.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EnrollmentQueryService.java @@ -43,6 +43,7 @@ import static org.hisp.dhis.common.ValueType.DATETIME; import static org.hisp.dhis.common.ValueType.NUMBER; import static org.hisp.dhis.common.ValueType.TEXT; +import static org.hisp.dhis.commons.util.TextUtils.EMPTY; import java.util.List; import lombok.RequiredArgsConstructor; @@ -54,6 +55,8 @@ import org.hisp.dhis.analytics.tracker.MetadataItemsHandler; import org.hisp.dhis.analytics.tracker.SchemeIdHandler; import org.hisp.dhis.common.DimensionItemKeywords.Keyword; +import org.hisp.dhis.common.DimensionType; +import org.hisp.dhis.common.DimensionalObject; import org.hisp.dhis.common.Grid; import org.hisp.dhis.common.GridHeader; import org.hisp.dhis.db.sql.SqlBuilder; @@ -98,6 +101,9 @@ public Grid getEnrollments(EventQueryParams params) { List keywords = getDimensionsKeywords(params); + // Retain original period dimensions before consuming them for date filtering + List periods = getPeriodDimensions(params); + params = new EventQueryParams.Builder(params).withStartEndDatesForPeriods().build(); // Headers @@ -113,6 +119,14 @@ public Grid getEnrollments(EventQueryParams params) { count = addData(grid, params); } + // Re-add period items for metadata generation (items + dimensions sections) + if (!periods.isEmpty()) { + params = + new EventQueryParams.Builder(params) + .withPeriods(periods.stream().flatMap(p -> p.getItems().stream()).toList(), EMPTY) + .build(); + } + // Metadata metadataHandler.addMetadata(grid, params, keywords); @@ -233,4 +247,10 @@ private long addData(Grid grid, EventQueryParams params) { return count; } + + private static List getPeriodDimensions(EventQueryParams params) { + return params.getDimensions().stream() + .filter(d -> d.getDimensionType() == DimensionType.PERIOD) + .toList(); + } } 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 c8641519c527..aaeb1d732146 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 @@ -48,6 +48,8 @@ import static org.hisp.dhis.analytics.DataQueryParams.VALUE_ID; import static org.hisp.dhis.analytics.event.EventAnalyticsUtils.addValues; import static org.hisp.dhis.analytics.event.EventAnalyticsUtils.generateEventDataPermutations; +import static org.hisp.dhis.analytics.event.LabelMapper.getEnrollmentDateLabel; +import static org.hisp.dhis.analytics.event.LabelMapper.getIncidentDateLabel; import static org.hisp.dhis.analytics.tracker.ResponseHelper.UNLIMITED_PAGING; import static org.hisp.dhis.analytics.tracker.ResponseHelper.addPaging; import static org.hisp.dhis.analytics.tracker.ResponseHelper.getDimensionsKeywords; @@ -73,12 +75,15 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.hisp.dhis.analytics.AnalyticsMetaDataKey; import org.hisp.dhis.analytics.AnalyticsSecurityManager; import org.hisp.dhis.analytics.EventAnalyticsDimensionalItem; import org.hisp.dhis.analytics.OrgUnitFieldType; import org.hisp.dhis.analytics.cache.AnalyticsCache; +import org.hisp.dhis.analytics.common.ColumnHeader; import org.hisp.dhis.analytics.event.EnrollmentAnalyticsManager; import org.hisp.dhis.analytics.event.EventAnalyticsManager; import org.hisp.dhis.analytics.event.EventDataQueryService; @@ -102,6 +107,8 @@ import org.hisp.dhis.feedback.ErrorMessage; import org.hisp.dhis.legend.Legend; import org.hisp.dhis.option.Option; +import org.hisp.dhis.period.PeriodDimension; +import org.hisp.dhis.program.Program; import org.hisp.dhis.system.grid.ListGrid; import org.hisp.dhis.trackedentity.TrackedEntityAttributeService; import org.hisp.dhis.util.Timer; @@ -326,16 +333,76 @@ private void addItemHeaders(EventQueryParams params, Grid grid) { private void addDimensionHeaders(EventQueryParams params, Grid grid) { for (DimensionalObject dimension : params.getDimensions()) { + String headerName = getDimensionHeaderName(dimension); + String headerColumn = getDimensionHeaderColumn(dimension, params); + + grid.addHeader(new GridHeader(headerName, headerColumn, TEXT, false, true)); + } + + if (params.hasEnrollmentOuDimension()) { grid.addHeader( new GridHeader( - dimension.getDimension(), - dimension.getDisplayProperty(params.getDisplayProperty()), + ColumnHeader.ENROLLMENT_OU.getItem(), + ColumnHeader.ENROLLMENT_OU.getName(), TEXT, false, true)); } } + private String getDimensionHeaderName(DimensionalObject dimension) { + return getStaticDateField(dimension).map(this::toDateFieldKey).orElse(dimension.getDimension()); + } + + private String getDimensionHeaderColumn(DimensionalObject dimension, EventQueryParams params) { + return getStaticDateField(dimension) + .map(dateField -> getDateFieldLabel(dateField, params.getProgram())) + .orElse(dimension.getDisplayProperty(params.getDisplayProperty())); + } + + private Optional getStaticDateField(DimensionalObject dimension) { + if (!PERIOD_DIM_ID.equals(dimension.getDimension())) { + return Optional.empty(); + } + + Set dateFields = + dimension.getItems().stream() + .filter(PeriodDimension.class::isInstance) + .map(PeriodDimension.class::cast) + .map(PeriodDimension::getDateField) + .collect(java.util.stream.Collectors.toSet()); + + if (dateFields.size() == 1) { + String dateField = dateFields.iterator().next(); + if (dateField != null) { + return Optional.of(dateField); + } + } + + return Optional.empty(); + } + + private String toDateFieldKey(String dateField) { + return dateField.toLowerCase().replace("_", ""); + } + + private String getDateFieldLabel(String dateField, Program program) { + return switch (dateField) { + case "ENROLLMENT_DATE" -> getEnrollmentDateLabel(program, toDateFieldDisplayName(dateField)); + case "INCIDENT_DATE" -> getIncidentDateLabel(program, toDateFieldDisplayName(dateField)); + default -> toDateFieldDisplayName(dateField); + }; + } + + private 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); + } + private void addValueHeader(Grid grid) { grid.addHeader(new GridHeader(VALUE_ID, VALUE_HEADER_NAME, NUMBER, false, false)); } diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EventQueryService.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EventQueryService.java index 124d38f25caf..5a996b84d72b 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EventQueryService.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/EventQueryService.java @@ -46,6 +46,7 @@ import static org.hisp.dhis.common.ValueType.DATETIME; import static org.hisp.dhis.common.ValueType.NUMBER; import static org.hisp.dhis.common.ValueType.TEXT; +import static org.hisp.dhis.commons.util.TextUtils.EMPTY; import static org.hisp.dhis.feedback.ErrorCode.E7218; import java.util.List; @@ -59,6 +60,8 @@ import org.hisp.dhis.analytics.tracker.MetadataItemsHandler; import org.hisp.dhis.analytics.tracker.SchemeIdHandler; import org.hisp.dhis.common.DimensionItemKeywords.Keyword; +import org.hisp.dhis.common.DimensionType; +import org.hisp.dhis.common.DimensionalObject; import org.hisp.dhis.common.Grid; import org.hisp.dhis.common.GridHeader; import org.hisp.dhis.db.sql.SqlBuilder; @@ -103,6 +106,9 @@ public Grid getEvents(EventQueryParams params) { List keywords = getDimensionsKeywords(params); + // Retain original period dimensions before consuming them for date filtering + List periods = getPeriodDimensions(params); + params = new EventQueryParams.Builder(params).withStartEndDatesForPeriods().build(); // Headers @@ -118,6 +124,14 @@ public Grid getEvents(EventQueryParams params) { count = addData(grid, params); } + // Re-add period items for metadata generation (items + dimensions sections) + if (!periods.isEmpty()) { + params = + new EventQueryParams.Builder(params) + .withPeriods(periods.stream().flatMap(p -> p.getItems().stream()).toList(), EMPTY) + .build(); + } + // Metadata metadataHandler.addMetadata(grid, params, keywords); @@ -341,4 +355,10 @@ private long addData(Grid grid, EventQueryParams params) { private boolean isGeospatialSupport() { return sqlBuilder.supportsGeospatialData(); } + + private static List getPeriodDimensions(EventQueryParams params) { + return params.getDimensions().stream() + .filter(d -> d.getDimensionType() == DimensionType.PERIOD) + .toList(); + } } 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 5adfea9c3be4..c9f2fa5b1eed 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 @@ -935,7 +935,7 @@ private void addBaseAggregationCte( List columns = new ArrayList<>(); // Add base column - addDimensionSelectColumns(columns, params, true); + addDimensionSelectColumns(columns, params, true, true); SelectBuilder sb = new SelectBuilder(); sb.addColumn(ENROLLMENT_COL, "ax"); 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 30b5fcbc6ed9..cb8ec02fc593 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 @@ -77,6 +77,7 @@ import org.hisp.dhis.analytics.common.ProgramIndicatorSubqueryBuilder; import org.hisp.dhis.analytics.event.EventAnalyticsManager; import org.hisp.dhis.analytics.event.EventQueryParams; +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.stage.StageQuerySqlFacade; @@ -429,9 +430,8 @@ protected String getColumnWithCte( @Override void addFromClause(SelectBuilder sb, EventQueryParams params) { - - // FIXME: use same logic from `getFromClause` method - sb.from(params.getTableName(), "ax"); + sb.from(params.getTableName(), ANALYTICS_TBL_ALIAS); + OrgUnitSqlCoordinator.addJoinIfNeeded(sb, params); } /** @@ -514,31 +514,32 @@ private String getCoordinateSelectExpression(EventQueryParams params) { */ @Override protected String getFromClause(EventQueryParams params) { - String sql = " from "; + StringBuilder sql = new StringBuilder(" from "); if (params.getAggregationTypeFallback().isFirstOrLastPeriodAggregationType()) { - sql += getFirstOrLastValueSubquerySql(params); + sql.append(getFirstOrLastValueSubquerySql(params)); } else { - sql += params.getTableName(); + sql.append(params.getTableName()); } - sql += " as " + ANALYTICS_TBL_ALIAS + " "; + sql.append(" as ").append(ANALYTICS_TBL_ALIAS).append(" "); if (params.hasTimeField()) { String joinCol = quoteAlias(params.getTimeFieldAsField(AnalyticsType.EVENT)); - sql += - "left join analytics_rs_dateperiodstructure as " - + DATE_PERIOD_STRUCT_ALIAS - + " on cast(" - + joinCol - + " as date) = " - + DATE_PERIOD_STRUCT_ALIAS - + "." - + quote("dateperiod") - + " "; + sql.append("left join analytics_rs_dateperiodstructure as ") + .append(DATE_PERIOD_STRUCT_ALIAS) + .append(" on cast(") + .append(joinCol) + .append(" as date) = ") + .append(DATE_PERIOD_STRUCT_ALIAS) + .append(".") + .append(quote("dateperiod")) + .append(" "); } - return sql + joinOrgUnitTables(params, getAnalyticsType()); + OrgUnitSqlCoordinator.appendLegacyJoin(sql, params); + + return sql.append(joinOrgUnitTables(params, getAnalyticsType())).toString(); } /** @@ -762,6 +763,10 @@ protected String getWhereClause(EventQueryParams params) { sql += hlp.whereAnd() + " completeddate is not null "; } + StringBuilder enrollmentOuSql = new StringBuilder(); + OrgUnitSqlCoordinator.appendWherePredicateIfNeeded(enrollmentOuSql, hlp, params, sqlBuilder); + sql += enrollmentOuSql; + if (params.hasBbox()) { sql += hlp.whereAnd() @@ -1061,7 +1066,8 @@ private List resolveCoordinateFieldsColumnNames( void addSelectClause(SelectBuilder sb, EventQueryParams params, CteContext cteContext) { List columns = new ArrayList<>(getStandardColumns(params)); - addDimensionSelectColumns(columns, params, false); + addDimensionSelectColumns(columns, params, false, false); + OrgUnitSqlCoordinator.addQuerySelectColumns(columns, params); addEventsItemSelectColumns(columns, params, cteContext); columns.forEach( diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitRowAccess.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitRowAccess.java new file mode 100644 index 000000000000..9beb7b8bc787 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitRowAccess.java @@ -0,0 +1,47 @@ +/* + * 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.ou; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** Column names used when reading ENROLLMENT_OU values from result rows. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class OrgUnitRowAccess { + + /** + * Returns the result-set column alias used for ENROLLMENT_OU values in aggregated event rows. + * + * @return enrollment OU result column alias + */ + public static String enrollmentOuResultColumn() { + return OrgUnitSqlConstants.ENROLLMENT_OU_RESULT_ALIAS; + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitSqlConstants.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitSqlConstants.java new file mode 100644 index 000000000000..5cbe847b4872 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitSqlConstants.java @@ -0,0 +1,55 @@ +/* + * 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.ou; + +import static org.hisp.dhis.analytics.AnalyticsConstants.ANALYTICS_TBL_ALIAS; +import static org.hisp.dhis.analytics.AnalyticsConstants.ORG_UNIT_STRUCT_ALIAS; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.hisp.dhis.analytics.common.ColumnHeader; +import org.hisp.dhis.analytics.table.EventAnalyticsColumnName; + +/** Shared SQL identifiers for ENROLLMENT_OU query and aggregate handling. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class OrgUnitSqlConstants { + + public static final String ORG_UNIT_STRUCTURE_TABLE = "analytics_rs_orgunitstructure"; + public static final String EVENT_TABLE_ALIAS = ANALYTICS_TBL_ALIAS; + public static final String ORG_UNIT_STRUCTURE_ALIAS = ORG_UNIT_STRUCT_ALIAS; + public static final String EVENT_ENROLLMENT_OU_COLUMN = + EventAnalyticsColumnName.ENROLLMENT_OU_COLUMN_NAME; + public static final String ORG_UNIT_UID_COLUMN = "organisationunituid"; + public static final String ORG_UNIT_NAME_COLUMN = "name"; + public static final String ORG_UNIT_LEVEL_COLUMN = "level"; + public static final String ENROLLMENT_OU_RESULT_ALIAS = ColumnHeader.ENROLLMENT_OU.getItem(); + public static final String ENROLLMENT_OU_NAME_RESULT_ALIAS = + ColumnHeader.ENROLLMENT_OU_NAME.getItem(); +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitSqlCoordinator.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitSqlCoordinator.java new file mode 100644 index 000000000000..5d87ebceac68 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitSqlCoordinator.java @@ -0,0 +1,153 @@ +/* + * 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.ou; + +import static java.util.stream.Collectors.joining; +import static org.hisp.dhis.common.IdentifiableObjectUtils.getUids; + +import java.util.ArrayList; +import java.util.List; +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.common.DimensionalItemObject; +import org.hisp.dhis.commons.util.SqlHelper; +import org.hisp.dhis.db.sql.AnalyticsSqlBuilder; +import org.hisp.dhis.program.AnalyticsType; + +/** Orchestrates ENROLLMENT_OU SQL clauses for query and aggregate paths. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class OrgUnitSqlCoordinator { + + /** + * Adds the ENROLLMENT_OU join to a {@link SelectBuilder} query when enrollment OU is used as a + * filter or dimension. + * + * @param sb builder being assembled + * @param params query parameters + */ + public static void addJoinIfNeeded(SelectBuilder sb, EventQueryParams params) { + if (!params.hasEnrollmentOu()) { + return; + } + + sb.innerJoin( + OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_TABLE, + OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_ALIAS, + OrgUnitSqlFragments::joinCondition); + } + + /** + * Appends the ENROLLMENT_OU legacy join clause for string-based SQL generation. + * + * @param sql SQL buffer being assembled + * @param params query parameters + */ + public static void appendLegacyJoin(StringBuilder sql, EventQueryParams params) { + if (params.hasEnrollmentOu()) { + sql.append(OrgUnitSqlFragments.innerJoinClause()); + } + } + + /** + * Adds ENROLLMENT_OU aggregate select/group-by columns when required. + * + *

This is only applicable for event analytics aggregate queries where ENROLLMENT_OU is a + * dimension. + * + * @param columns mutable output column list + * @param params query parameters + * @param isGroupBy whether the target list is used for group-by + * @param isAggregated whether the query is in aggregated mode + * @param analyticsType current analytics type + */ + public static void addDimensionSelectColumns( + List columns, + EventQueryParams params, + boolean isGroupBy, + boolean isAggregated, + AnalyticsType analyticsType) { + if (isAggregated && params.hasEnrollmentOuDimension() && analyticsType == AnalyticsType.EVENT) { + columns.add(OrgUnitSqlFragments.selectEnrollmentOuUid(isGroupBy)); + } + } + + /** + * Adds ENROLLMENT_OU query output columns (UID and name) for event query endpoint rows. + * + * @param columns mutable output column list + * @param params query parameters + */ + public static void addQuerySelectColumns(List columns, EventQueryParams params) { + if (!params.hasEnrollmentOuDimension()) { + return; + } + + columns.add(OrgUnitSqlFragments.selectEnrollmentOuUid(false)); + columns.add(OrgUnitSqlFragments.selectEnrollmentOuName()); + } + + /** + * Appends ENROLLMENT_OU where conditions, combining UID and level predicates with OR semantics. + * + * @param sql SQL buffer being assembled + * @param hlp helper used to add {@code where/and} prefixes + * @param params query parameters + * @param sqlBuilder SQL dialect helper used for quoting UID lists + */ + public static void appendWherePredicateIfNeeded( + StringBuilder sql, SqlHelper hlp, EventQueryParams params, AnalyticsSqlBuilder sqlBuilder) { + if (!params.hasEnrollmentOu()) { + return; + } + + List predicates = new ArrayList<>(); + List enrollmentOuItems = params.getAllEnrollmentOuItemsForSql(); + + if (!enrollmentOuItems.isEmpty()) { + predicates.add( + OrgUnitSqlFragments.predicateByUids( + sqlBuilder.singleQuotedCommaDelimited(getUids(enrollmentOuItems)))); + } + + if (!params.getAllEnrollmentOuLevelsForSql().isEmpty()) { + String levels = + params.getAllEnrollmentOuLevelsForSql().stream() + .map(String::valueOf) + .collect(joining(",")); + predicates.add(OrgUnitSqlFragments.predicateByLevels(levels)); + } + + if (!predicates.isEmpty()) { + sql.append(hlp.whereAnd()).append(" (").append(String.join(" or ", predicates)).append(") "); + } + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitSqlFragments.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitSqlFragments.java new file mode 100644 index 000000000000..927499b77e5f --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/OrgUnitSqlFragments.java @@ -0,0 +1,128 @@ +/* + * 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.ou; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** Pure SQL fragments used by ENROLLMENT_OU support. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class OrgUnitSqlFragments { + + /** + * Builds the join predicate between the event analytics table and org unit structure table for + * enrollment OU. + * + * @param orgUnitAlias alias used for {@code analytics_rs_orgunitstructure} in the current query + * @return SQL join condition using quoted identifiers + */ + public static String joinCondition(String orgUnitAlias) { + return quotedColumn( + OrgUnitSqlConstants.EVENT_TABLE_ALIAS, OrgUnitSqlConstants.EVENT_ENROLLMENT_OU_COLUMN) + + " = " + + quotedColumn(orgUnitAlias, OrgUnitSqlConstants.ORG_UNIT_UID_COLUMN); + } + + /** + * Builds the legacy (string-based) inner join clause required when ENROLLMENT_OU is part of query + * constraints or output. + * + * @return full {@code inner join ... on ...} clause with trailing space + */ + public static String innerJoinClause() { + return "inner join " + + OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_TABLE + + " as " + + OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_ALIAS + + " on " + + joinCondition(OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_ALIAS) + + " "; + } + + /** + * Builds the enrollment OU UID projection for select/group-by clauses. + * + * @param groupBy when true, returns a raw column reference suitable for group-by; when false, + * returns a projected alias suitable for select output + * @return SQL fragment for enrollment OU UID + */ + public static String selectEnrollmentOuUid(boolean groupBy) { + String uidCol = + quotedColumn( + OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_ALIAS, OrgUnitSqlConstants.ORG_UNIT_UID_COLUMN); + return groupBy ? uidCol : uidCol + " as " + OrgUnitSqlConstants.ENROLLMENT_OU_RESULT_ALIAS; + } + + /** + * Builds the enrollment OU display name projection used by event query output. + * + * @return SQL fragment mapping org unit name to the enrollment OU name alias + */ + public static String selectEnrollmentOuName() { + return quotedColumn( + OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_ALIAS, OrgUnitSqlConstants.ORG_UNIT_NAME_COLUMN) + + " as " + + OrgUnitSqlConstants.ENROLLMENT_OU_NAME_RESULT_ALIAS; + } + + /** + * Builds a UID-membership predicate for enrollment OU. + * + * @param quotedUidList comma-delimited and quoted UID values + * @return SQL predicate fragment for org unit UID filtering + */ + public static String predicateByUids(String quotedUidList) { + return " " + + quotedColumn( + OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_ALIAS, OrgUnitSqlConstants.ORG_UNIT_UID_COLUMN) + + " in (" + + quotedUidList + + ") "; + } + + /** + * Builds a level-membership predicate for enrollment OU. + * + * @param commaSeparatedLevels comma-delimited level numbers + * @return SQL predicate fragment for org unit level filtering + */ + public static String predicateByLevels(String commaSeparatedLevels) { + return " " + + quotedColumn( + OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_ALIAS, OrgUnitSqlConstants.ORG_UNIT_LEVEL_COLUMN) + + " in (" + + commaSeparatedLevels + + ") "; + } + + private static String quotedColumn(String alias, String column) { + return alias + ".\"" + column + "\""; + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/package-info.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/package-info.java new file mode 100644 index 000000000000..25bdaf82318a --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/ou/package-info.java @@ -0,0 +1,13 @@ +/** + * SQL support components for {@code ENROLLMENT_OU} handling in event analytics. + * + *

This package isolates: + * + *

    + *
  • shared SQL constants and aliases for enrollment org unit columns, + *
  • reusable SQL fragment builders for select/join/where clauses, + *
  • coordination logic that applies those fragments across query and aggregate flows, and + *
  • row-access helpers for reading {@code ENROLLMENT_OU} values from result sets. + *
+ */ +package org.hisp.dhis.analytics.event.data.ou; 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 680784291817..bcffc4b93df5 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 @@ -321,6 +321,14 @@ private void applyOrganisationUnitConstraint(QueryParamsBuilder builder, DataQue return; } + // ENROLLMENT_OU in event queries has its own OU semantics via enrollment org unit predicates. + // Skip implicit default OU assignment only for ENROLLMENT_OU-only event queries. + if (params instanceof EventQueryParams eventParams + && eventParams.hasEnrollmentOu() + && !eventParams.hasOrganisationUnits()) { + return; + } + // ----------------------------------------------------------------- // Apply constraint as filter, and remove potential all-dimension // ----------------------------------------------------------------- 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 f520abe9c42d..1ae7bf9207c1 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 @@ -44,6 +44,7 @@ import java.util.Map; import java.util.Set; import lombok.NoArgsConstructor; +import org.hisp.dhis.analytics.common.ColumnHeader; import org.hisp.dhis.analytics.event.EventQueryParams; import org.hisp.dhis.common.DimensionalObject; import org.hisp.dhis.common.DisplayProperty; @@ -99,6 +100,23 @@ public static void addCommonHeaders( } } } + + 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)); + } } private static void addDimensionHeaders(Grid grid, List dimensions) { 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 f31bd5946613..7b43a273fd0c 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 @@ -48,7 +48,6 @@ import static org.hisp.dhis.common.DimensionalObjectUtils.asTypedList; import static org.hisp.dhis.common.DimensionalObjectUtils.getDimensionalItemIds; import static org.hisp.dhis.common.IdentifiableObjectUtils.getLocalPeriodIdentifiers; -import static org.hisp.dhis.common.IdentifiableObjectUtils.getUids; import static org.hisp.dhis.common.ValueType.ORGANISATION_UNIT; import static org.hisp.dhis.organisationunit.OrganisationUnit.getParentGraphMap; import static org.hisp.dhis.organisationunit.OrganisationUnit.getParentNameGraphMap; @@ -66,7 +65,9 @@ import javax.annotation.Nullable; import lombok.RequiredArgsConstructor; import org.hisp.dhis.analytics.AnalyticsSecurityManager; +import org.hisp.dhis.analytics.TimeField; import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.analytics.event.LabelMapper; import org.hisp.dhis.analytics.event.data.OrganisationUnitResolver; import org.hisp.dhis.analytics.orgunit.OrgUnitHelper; import org.hisp.dhis.analytics.util.AnalyticsUtils; @@ -85,6 +86,7 @@ import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.period.PeriodDimension; import org.hisp.dhis.period.PeriodType; +import org.hisp.dhis.program.Program; import org.hisp.dhis.user.CurrentUserUtil; import org.hisp.dhis.user.User; import org.hisp.dhis.user.UserService; @@ -241,6 +243,8 @@ private Map getMetadataItems( removeSyntheticDimensionMetadataKeys(metadataItemMap, params); addPeriodDimensionValueMetadata(metadataItemMap, params, includeDetails); + addDateFieldDimensionMetadata(metadataItemMap, params); + addEnrollmentOuMetadata(metadataItemMap, params, includeDetails); return metadataItemMap; } @@ -496,6 +500,86 @@ private void addPeriodDimensionMetadataValue( includeDetails ? periodDimension : null)); } + /** + * Adds metadata items for date-field-specific period dimension keys. For example, when periods + * have dateField="ENROLLMENT_DATE", adds an "enrollmentdate" entry with display name "Enrollment + * date". + * + * @param metadataItemMap the metadata item map. + * @param params the {@link EventQueryParams}. + */ + private void addDateFieldDimensionMetadata( + Map metadataItemMap, EventQueryParams params) { + List periodItems = params.getDimensionOrFilterItems(PERIOD_DIM_ID); + + // Source 1: period dimension items (available in aggregate path where periods are re-added) + periodItems.stream() + .filter(PeriodDimension.class::isInstance) + .map(PeriodDimension.class::cast) + .filter(pd -> pd.getDateField() != null) + .map(PeriodDimension::getDateField) + .distinct() + .forEach(dateField -> addDateFieldMetadataEntry(metadataItemMap, dateField, params)); + + // Source 2: timeDateRanges (available in query path where replacePeriodsWithDates() + // consumes periods and moves date field info to timeDateRanges) + params.getTimeDateRanges().keySet().stream() + .map(TimeField::name) + .forEach(dateField -> addDateFieldMetadataEntry(metadataItemMap, dateField, params)); + } + + private void addDateFieldMetadataEntry( + Map metadataItemMap, String dateField, EventQueryParams params) { + String key = toDateFieldKey(dateField); + if (!metadataItemMap.containsKey(key)) { + metadataItemMap.put(key, new MetadataItem(getDateFieldLabel(dateField, params.getProgram()))); + } + } + + /** + * Returns the display label for a date field, using the program's custom label if available, or + * falling back to the default display name. + */ + private static String getDateFieldLabel(String dateField, Program program) { + return switch (dateField) { + case "ENROLLMENT_DATE" -> + LabelMapper.getEnrollmentDateLabel(program, toDateFieldDisplayName(dateField)); + case "INCIDENT_DATE" -> + LabelMapper.getIncidentDateLabel(program, toDateFieldDisplayName(dateField)); + default -> toDateFieldDisplayName(dateField); + }; + } + + /** + * Converts a dateField name (e.g. "ENROLLMENT_DATE") to a display name (e.g. "Enrollment date"). + */ + 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); + } + + /** + * Adds metadata entries for enrollment org unit dimension items. Each item gets a MetadataItem + * with its display name. + */ + private void addEnrollmentOuMetadata( + Map metadataItemMap, EventQueryParams params, boolean includeDetails) { + if (!params.hasEnrollmentOuDimension()) { + return; + } + + for (DimensionalItemObject item : params.getEnrollmentOuDimensionItems()) { + metadataItemMap.put( + item.getUid(), + new MetadataItem( + item.getDisplayProperty(params.getDisplayProperty()), includeDetails ? item : null)); + } + } + /** * Adds the given item to the given metadata item map. * @@ -537,30 +621,66 @@ private Map> getDimensionItems( EventQueryParams params, Optional>> itemOptions) { Map> dimensionItems = new HashMap<>(); - dimensionItems.put(PERIOD_DIM_ID, resolvePeriodUids(params)); + addPeriodDimensionItems(dimensionItems, params); addDimensionsAndFilters(dimensionItems, params); addQueryItemDimensions(dimensionItems, params, itemOptions); addItemFiltersToDimensionItems(params.getItemFilters(), dimensionItems); + addEnrollmentOuDimensionItems(dimensionItems, params); return dimensionItems; } /** - * Resolves period UIDs based on calendar type. + * Separates period dimension items by dateField. Periods with a dateField (e.g. ENROLLMENT_DATE, + * INCIDENT_DATE) are placed under a key derived from the dateField name (e.g. "enrollmentdate", + * "incidentdate"). Periods without a dateField are placed under the standard "pe" key. * + * @param dimensionItems the dimension items map. * @param params the {@link EventQueryParams}. - * @return a list of period UIDs. */ - private List resolvePeriodUids(EventQueryParams params) { + private void addPeriodDimensionItems( + Map> dimensionItems, EventQueryParams params) { Calendar calendar = PeriodType.getCalendar(); - return calendar.isIso8601() - ? getUids(params.getDimensionOrFilterItems(PERIOD_DIM_ID)) - : getLocalPeriodIdentifiers(params.getDimensionOrFilterItems(PERIOD_DIM_ID), calendar); + List periodItems = params.getDimensionOrFilterItems(PERIOD_DIM_ID); + + Map> periodsByKey = new HashMap<>(); + List allPeriods = new ArrayList<>(); + + for (DimensionalItemObject item : periodItems) { + PeriodDimension period = (PeriodDimension) item; + String key = + period.getDateField() != null ? toDateFieldKey(period.getDateField()) : PERIOD_DIM_ID; + + String uid = + calendar.isIso8601() + ? period.getUid() + : getLocalPeriodIdentifiers(List.of(period), calendar).get(0); + + allPeriods.add(uid); + periodsByKey.computeIfAbsent(key, k -> new ArrayList<>()).add(uid); + } + + // Query endpoint compatibility: historically periods were consumed before metadata generation, + // so `metadata.dimensions.pe` ended up empty. Keep that legacy shape for query responses to + // avoid broad e2e/front-end expectation changes, while aggregate responses expose all periods. + periodsByKey.put(PERIOD_DIM_ID, params.isComingFromQuery() ? List.of() : allPeriods); + dimensionItems.putAll(periodsByKey); + dimensionItems.putIfAbsent(PERIOD_DIM_ID, List.of()); + } + + /** + * Converts a dateField name (e.g. "ENROLLMENT_DATE") to its metadata dimension key (e.g. + * "enrollmentdate"). + */ + static String toDateFieldKey(String dateField) { + return dateField.toLowerCase().replace("_", ""); } /** - * Adds dimensions and filters to the dimension items map. + * Adds dimensions and filters to the dimension items map. The period dimension is skipped because + * it is handled separately in {@link #addPeriodDimensionItems} to support date-field specific + * keys. * * @param dimensionItems the dimension items map. * @param params the {@link EventQueryParams}. @@ -572,10 +692,23 @@ private void addDimensionsAndFilters( continue; } + // Period dimension is handled by addPeriodDimensionItems + if (PERIOD_DIM_ID.equals(dim.getDimension())) { + continue; + } + dimensionItems.put(dim.getDimension(), getDimensionalItemIds(dim.getItems())); } } + private void addEnrollmentOuDimensionItems( + Map> dimensionItems, EventQueryParams params) { + if (params.hasEnrollmentOuDimension()) { + dimensionItems.put( + "enrollmentou", getDimensionalItemIds(params.getEnrollmentOuDimensionItems())); + } + } + private void removeSyntheticDimensionMetadataKeys( Map metadataItemMap, EventQueryParams params) { params.getDimensionsAndFilters().stream() 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 4d0c557a5fb6..0c4e37f3207c 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 @@ -50,6 +50,7 @@ import org.hisp.dhis.common.BaseDimensionalItemObject; import org.hisp.dhis.common.BaseDimensionalObject; import org.hisp.dhis.common.DateRange; +import org.hisp.dhis.common.DimensionalItemObject; import org.hisp.dhis.common.Locale; import org.hisp.dhis.common.QueryFilter; import org.hisp.dhis.common.QueryItem; @@ -1194,4 +1195,89 @@ void testGetDuplicateStageDimensionIdentifiersWithMixedOffsets() { duplicates.contains( psA.getUid() + "." + EventAnalyticsColumnName.OCCURRED_DATE_COLUMN_NAME + ".0")); } + + @Test + void testEnrollmentOuDimensionStorage() { + List items = List.of(ouA, ouB); + + EventQueryParams params = + new EventQueryParams.Builder().withEnrollmentOuDimension(items).build(); + + assertTrue(params.hasEnrollmentOuDimension()); + assertFalse(params.hasEnrollmentOuFilter()); + assertTrue(params.hasEnrollmentOu()); + assertEquals(2, params.getEnrollmentOuDimensionItems().size()); + assertEquals(2, params.getAllEnrollmentOuItems().size()); + } + + @Test + void testEnrollmentOuFilterStorage() { + List items = List.of(ouA); + + EventQueryParams params = new EventQueryParams.Builder().withEnrollmentOuFilter(items).build(); + + assertFalse(params.hasEnrollmentOuDimension()); + assertTrue(params.hasEnrollmentOuFilter()); + assertTrue(params.hasEnrollmentOu()); + assertEquals(1, params.getEnrollmentOuFilterItems().size()); + assertEquals(1, params.getAllEnrollmentOuItems().size()); + } + + @Test + void testEnrollmentOuDimensionAndFilter() { + List dimItems = List.of(ouA); + List filterItems = List.of(ouB); + + EventQueryParams params = + new EventQueryParams.Builder() + .withEnrollmentOuDimension(dimItems) + .withEnrollmentOuFilter(filterItems) + .build(); + + assertTrue(params.hasEnrollmentOuDimension()); + assertTrue(params.hasEnrollmentOuFilter()); + assertTrue(params.hasEnrollmentOu()); + assertEquals(2, params.getAllEnrollmentOuItems().size()); + } + + @Test + void testEnrollmentOuLevelsForSql() { + EventQueryParams params = + new EventQueryParams.Builder() + .withEnrollmentOuDimensionLevels(Set.of(3)) + .withEnrollmentOuFilterLevels(Set.of(4)) + .build(); + + assertTrue(params.hasEnrollmentOuDimension()); + assertTrue(params.hasEnrollmentOuFilter()); + assertTrue(params.hasEnrollmentOuLevelConstraint()); + assertEquals(Set.of(3, 4), params.getAllEnrollmentOuLevelsForSql()); + } + + @Test + void testEnrollmentOuCopiedInInstance() { + List items = List.of(ouA, ouB); + + EventQueryParams original = + new EventQueryParams.Builder() + .withEnrollmentOuDimension(items) + .withEnrollmentOuDimensionLevels(Set.of(4)) + .build(); + + EventQueryParams copy = new EventQueryParams.Builder(original).build(); + + assertTrue(copy.hasEnrollmentOuDimension()); + assertEquals(2, copy.getEnrollmentOuDimensionItems().size()); + assertEquals(Set.of(4), copy.getEnrollmentOuDimensionLevels()); + } + + @Test + void testHasEnrollmentOuReturnsFalseWhenNotSet() { + EventQueryParams params = new EventQueryParams.Builder().build(); + + assertFalse(params.hasEnrollmentOuDimension()); + assertFalse(params.hasEnrollmentOuFilter()); + assertFalse(params.hasEnrollmentOu()); + assertTrue(params.getAllEnrollmentOuItems().isEmpty()); + } } 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 7fb883bcaf39..35d1add1f308 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 @@ -65,6 +65,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -78,6 +79,7 @@ import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Stream; import javax.sql.rowset.RowSetMetaDataImpl; @@ -85,6 +87,7 @@ import org.hisp.dhis.analytics.AnalyticsAggregationType; import org.hisp.dhis.analytics.EventOutputType; import org.hisp.dhis.analytics.analyze.ExecutionPlanStore; +import org.hisp.dhis.analytics.common.CteContext; import org.hisp.dhis.analytics.common.ProgramIndicatorSubqueryBuilder; import org.hisp.dhis.analytics.event.EventQueryParams; import org.hisp.dhis.analytics.event.EventQueryParams.Builder; @@ -97,6 +100,7 @@ import org.hisp.dhis.analytics.event.data.stage.StageQuerySqlFacade; import org.hisp.dhis.analytics.table.EventAnalyticsColumnName; import org.hisp.dhis.analytics.table.util.ColumnMapper; +import org.hisp.dhis.analytics.util.sql.SelectBuilder; import org.hisp.dhis.common.AnalyticsCustomHeader; import org.hisp.dhis.common.BaseDimensionalItemObject; import org.hisp.dhis.common.BaseDimensionalObject; @@ -116,6 +120,7 @@ import org.hisp.dhis.db.sql.AnalyticsSqlBuilder; import org.hisp.dhis.db.sql.PostgreSqlAnalyticsSqlBuilder; import org.hisp.dhis.db.sql.PostgreSqlBuilder; +import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.organisationunit.OrganisationUnitService; import org.hisp.dhis.period.PeriodDimension; import org.hisp.dhis.period.PeriodTypeEnum; @@ -134,6 +139,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -1349,6 +1355,216 @@ private QueryItem buildQueryItemWithGroupAndFilters( return queryItem; } + @Test + void testLegacySelectColumnsDoesNotIncludeEnrollmentOuColumns() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + + List columns = eventSubject.getSelectColumns(params, false); + + assertTrue(columns.stream().noneMatch(c -> c.contains("enrollmentou"))); + assertTrue(columns.stream().noneMatch(c -> c.contains("enrollmentouname"))); + } + + @Test + void testLegacyGroupByColumnsDoesNotIncludeEnrollmentOuColumns() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + + List columns = eventSubject.getGroupByColumnNames(params, false); + + assertTrue(columns.stream().noneMatch(c -> c.contains("enrollmentou"))); + assertTrue(columns.stream().noneMatch(c -> c.contains("enrollmentouname"))); + } + + @Test + void testAggregatedLegacySelectColumnsIncludesEnrollmentOuColumn() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + + List columns = eventSubject.getSelectColumns(params, true); + + assertTrue(columns.stream().anyMatch(c -> c.contains("as enrollmentou"))); + } + + @Test + void testAggregatedLegacyGroupByColumnsIncludesEnrollmentOuColumn() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + + List columns = eventSubject.getGroupByColumnNames(params, true); + + assertTrue(columns.stream().anyMatch(c -> c.contains("ous.\"organisationunituid\""))); + } + + @Test + void testEnrollmentOuInWhereClause() { + OrganisationUnit ouA = createOrganisationUnit('A'); + OrganisationUnit ouB = createOrganisationUnit('B'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuFilter(List.of(ouA, ouB)) + .build(); + + String whereClause = eventSubject.getWhereClause(params); + + assertThat(whereClause, containsString("ous.\"organisationunituid\"")); + assertThat(whereClause, containsString(ouA.getUid())); + assertThat(whereClause, containsString(ouB.getUid())); + } + + @Test + void testFromClauseIncludesEnrollmentOuJoinWhenEnrollmentOuIsUsed() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withTableName("analytics_event_test") + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + + String fromClause = eventSubject.getFromClause(params); + + assertThat(fromClause, containsString("inner join analytics_rs_orgunitstructure as ous")); + assertThat(fromClause, containsString("ax.\"enrollmentou\" = ous.\"organisationunituid\"")); + } + + @Test + void testExperimentalFromClauseIncludesEnrollmentOuJoinWhenDimension() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withTableName("analytics_event_test") + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + + SelectBuilder sb = new SelectBuilder(); + eventSubject.addFromClause(sb, params); + String fromClause = sb.build(); + + assertThat(fromClause, containsString("analytics_rs_orgunitstructure")); + assertThat(fromClause, containsString("ous")); + assertThat(fromClause, containsString("ax.\"enrollmentou\"")); + } + + @Test + void testExperimentalSelectClauseIncludesEnrollmentOuColumnsWhenDimension() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + + SelectBuilder sb = new SelectBuilder(); + eventSubject.addSelectClause( + sb, params, new CteContext(org.hisp.dhis.analytics.common.EndpointItem.EVENT)); + String selectClause = sb.build(); + + assertThat(selectClause, containsString("ous.\"organisationunituid\" as enrollmentou")); + assertThat(selectClause, containsString("ous.\"name\" as enrollmentouname")); + } + + @Test + void testEnrollmentOuLevelConstraintUsesOrgUnitLevelColumn() { + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuFilterLevels(Set.of(4)) + .build(); + + String whereClause = eventSubject.getWhereClause(params); + + assertThat(whereClause, containsString("ous.\"level\" in (4)")); + assertThat(whereClause.contains("uidlevel"), is(false)); + } + + @Test + void testEnrollmentOuAndExplicitOuBothAppearInWhereClause() { + OrganisationUnit ouA = createOrganisationUnit('A'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withStartDate(from) + .withEndDate(to) + .withOrganisationUnits(List.of(ouA)) + .withEnrollmentOuFilterLevels(Set.of(4)) + .build(); + + String whereClause = eventSubject.getWhereClause(params); + + assertThat(whereClause, containsString("uidlevel1")); + assertThat(whereClause, containsString("ous.\"level\" in (4)")); + } + + @Test + void testGetEventCountIncludesEnrollmentOuJoinForLevelFilter() { + when(jdbcTemplate.queryForObject(any(String.class), eq(Long.class))).thenReturn(1L); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withTableName("analytics_event_test") + .withStartDate(from) + .withEndDate(to) + .withEnrollmentOuFilterLevels(Set.of(4)) + .build(); + + eventSubject.getEventCount(params); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).queryForObject(sqlCaptor.capture(), eq(Long.class)); + + String sql = sqlCaptor.getValue(); + assertThat(sql, containsString("inner join analytics_rs_orgunitstructure as ous")); + assertThat(sql, containsString("ous.\"level\" in (4)")); + } + private EventQueryParams getEventQueryParamsForCoordinateFieldsTest( List coordinateFields) { DataElement deA = createDataElement('A', TEXT, AggregationType.NONE); 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 d70483984471..ce4f8acb5250 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 @@ -36,6 +36,7 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hisp.dhis.test.TestBase.createDataElement; +import static org.hisp.dhis.test.TestBase.createOrganisationUnit; import static org.hisp.dhis.test.TestBase.createProgram; import static org.hisp.dhis.test.TestBase.createProgramIndicator; import static org.mockito.Mockito.verify; @@ -46,6 +47,7 @@ import java.util.function.Function; import org.hisp.dhis.analytics.AggregationType; import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.analytics.event.data.ou.OrgUnitRowAccess; import org.hisp.dhis.common.BaseDimensionalObject; import org.hisp.dhis.common.DimensionType; import org.hisp.dhis.common.IdScheme; @@ -452,4 +454,25 @@ void testBuildRowWithOutputIdSchemeUid() { assertThat(row, hasSize(2)); assertThat(row.get(0), is("someValue")); } + + @Test + void testBuildRowWithEnrollmentOuDimensionUsesCentralizedColumnName() { + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withEnrollmentOuDimension(List.of(createOrganisationUnit('A'))) + .build(); + + when(rowSet.getString(OrgUnitRowAccess.enrollmentOuResultColumn())).thenReturn("ouUid"); + when(rowSet.getInt("value")).thenReturn(5); + + List row = + AggregatedRowBuilder.create(params, rowSet, sqlBuilder, columnAliasResolver, itemIdProvider) + .build(); + + verify(rowSet).getString(OrgUnitRowAccess.enrollmentOuResultColumn()); + assertThat(row, hasSize(2)); + assertThat(row.get(0), is("ouUid")); + assertThat(row.get(1), is(5)); + } } 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 f02a3e00cd4a..4b5d5f848ba6 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 @@ -36,30 +36,43 @@ import static org.hisp.dhis.test.TestBase.createOrganisationUnit; import static org.hisp.dhis.test.TestBase.createProgram; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import org.hisp.dhis.analytics.AggregationType; import org.hisp.dhis.analytics.DataQueryService; import org.hisp.dhis.analytics.EventOutputType; +import org.hisp.dhis.analytics.event.EventQueryParams; import org.hisp.dhis.analytics.event.QueryItemLocator; import org.hisp.dhis.analytics.event.data.queryitem.QueryItemFilterHandlerRegistry; +import org.hisp.dhis.common.BaseDimensionalItemObject; import org.hisp.dhis.common.BaseDimensionalObject; +import org.hisp.dhis.common.DimensionType; +import org.hisp.dhis.common.DimensionalObject; import org.hisp.dhis.common.EventDataQueryRequest; import org.hisp.dhis.common.IdScheme; import org.hisp.dhis.common.IllegalQueryException; +import org.hisp.dhis.common.QueryItem; +import org.hisp.dhis.common.QueryOperator; +import org.hisp.dhis.common.ValueType; import org.hisp.dhis.dataelement.DataElementService; import org.hisp.dhis.feedback.ErrorCode; import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.organisationunit.OrganisationUnitService; import org.hisp.dhis.program.Program; import org.hisp.dhis.program.ProgramService; import org.hisp.dhis.program.ProgramStageService; @@ -79,6 +92,7 @@ class DefaultEventDataQueryServiceTest { @Mock private QueryItemLocator queryItemLocator; @Mock private TrackedEntityAttributeService attributeService; @Mock private DataQueryService dataQueryService; + @Mock private OrganisationUnitService organisationUnitService; private DefaultEventDataQueryService subject; private Program program; @@ -94,6 +108,7 @@ void setUp() { queryItemLocator, attributeService, dataQueryService, + organisationUnitService, new QueryItemFilterHandlerRegistry()); OrganisationUnit ou = createOrganisationUnit('A'); @@ -110,12 +125,12 @@ void setUp() { anyList(), anyBoolean(), any())) - .thenReturn(new BaseDimensionalObject("pe")); + .thenReturn(new BaseDimensionalObject("pe", DimensionType.PERIOD, List.of())); lenient() .when( dataQueryService.getDimension( anyString(), anyList(), any(), anyList(), anyBoolean(), any(), any(IdScheme.class))) - .thenReturn(new BaseDimensionalObject("pe")); + .thenReturn(new BaseDimensionalObject("pe", DimensionType.PERIOD, List.of())); } @Test @@ -173,6 +188,366 @@ void getFromRequestAcceptsAndNormalizesCreatedDateDimensionForEventAggregate() { any()); } + @Test + void getFromRequestRoutesOperatorFilterThroughDateHandler() { + // Return null for ENROLLMENT_DATE so the flow falls back to getQueryItem + when(dataQueryService.getDimension( + eq("ENROLLMENT_DATE"), + anyList(), + any(), + anyList(), + anyBoolean(), + any(), + any(IdScheme.class))) + .thenReturn(null); + + when(queryItemLocator.getQueryItemFromDimension( + "ENROLLMENT_DATE", program, EventOutputType.EVENT)) + .thenReturn(createDateQueryItem("enrollmentdate")); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .filter(Set.of(Set.of("ENROLLMENT_DATE:GT:2023-01-01"))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertEquals(1, params.getItemFilters().size()); + QueryItem filter = params.getItemFilters().get(0); + assertEquals("enrollmentdate", filter.getItemId()); + assertEquals(1, filter.getFilters().size()); + assertEquals(QueryOperator.GT, filter.getFilters().get(0).getOperator()); + assertEquals("2023-01-01", filter.getFilters().get(0).getFilter()); + } + + @Test + void getFromRequestRoutesMultiOperatorDateFilterThroughDateHandler() { + when(dataQueryService.getDimension( + eq("ENROLLMENT_DATE"), + anyList(), + any(), + anyList(), + anyBoolean(), + any(), + any(IdScheme.class))) + .thenReturn(null); + + when(queryItemLocator.getQueryItemFromDimension( + "ENROLLMENT_DATE", program, EventOutputType.EVENT)) + .thenReturn(createDateQueryItem("enrollmentdate")); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .filter(Set.of(Set.of("ENROLLMENT_DATE:GT:2023-01-01:LT:2023-12-31"))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertEquals(1, params.getItemFilters().size()); + QueryItem filter = params.getItemFilters().get(0); + assertEquals("enrollmentdate", filter.getItemId()); + assertEquals(2, filter.getFilters().size()); + assertEquals(QueryOperator.GT, filter.getFilters().get(0).getOperator()); + assertEquals("2023-01-01", filter.getFilters().get(0).getFilter()); + assertEquals(QueryOperator.LT, filter.getFilters().get(1).getOperator()); + assertEquals("2023-12-31", filter.getFilters().get(1).getFilter()); + } + + @Test + void getFromRequestStillNormalizesIsoPeriodForEnrollmentDate() { + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .dimension(Set.of(Set.of("ENROLLMENT_DATE:202301"))) + .build(); + + subject.getFromRequest(request); + + verify(dataQueryService) + .getDimension( + eq("pe"), + eq(List.of("202301:ENROLLMENT_DATE")), + eq(request), + anyList(), + eq(true), + any()); + } + + private QueryItem createDateQueryItem(String columnName) { + return new QueryItem( + new BaseDimensionalItemObject(columnName), + program, + null, + ValueType.DATE, + AggregationType.NONE, + null); + } + + @Test + void getFromRequestMergesDateFieldDimensionWithExplicitPeriodDimension() { + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .dimension(Set.of(Set.of("pe:LAST_12_MONTHS"), Set.of("ENROLLMENT_DATE:2021"))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertTrue(params.getDuplicateDimensions().isEmpty()); + } + + @Test + void getFromRequestPreservesFirstPePositionRelativeToOtherDimensions() { + OrganisationUnit ou = createOrganisationUnit('B'); + + BaseDimensionalObject peDimension = + new BaseDimensionalObject("pe", DimensionType.PERIOD, List.of()); + BaseDimensionalObject ouDimension = + new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(ou)); + + when(dataQueryService.getDimension( + eq("pe"), eq(List.of("2019", "2020")), any(), anyList(), anyBoolean(), any())) + .thenReturn(peDimension); + when(dataQueryService.getDimension( + eq("ou"), eq(List.of(ou.getUid())), any(), anyList(), anyBoolean(), any())) + .thenReturn(ouDimension); + + Set> dimensions = new LinkedHashSet<>(); + dimensions.add(Set.of("pe:2019;2020")); + dimensions.add(Set.of("ou:" + ou.getUid())); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT).dimension(dimensions).build(); + + EventQueryParams params = subject.getFromRequest(request); + + List dimensionOrder = + params.getDimensions().stream().map(DimensionalObject::getDimension).toList(); + + assertEquals(List.of("pe", "ou"), dimensionOrder); + } + + @Test + void getFromRequestPreservesPePositionWithMixedStaticDateAndExplicitPeriod() { + OrganisationUnit ou = createOrganisationUnit('B'); + + BaseDimensionalObject peDimension = + new BaseDimensionalObject("pe", DimensionType.PERIOD, List.of()); + BaseDimensionalObject ouDimension = + new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(ou)); + + when(dataQueryService.getDimension( + eq("pe"), + eq(List.of("2021:ENROLLMENT_DATE", "LAST_12_MONTHS")), + any(), + anyList(), + anyBoolean(), + any())) + .thenReturn(peDimension); + when(dataQueryService.getDimension( + eq("ou"), eq(List.of(ou.getUid())), any(), anyList(), anyBoolean(), any())) + .thenReturn(ouDimension); + + Set> dimensions = new LinkedHashSet<>(); + dimensions.add(Set.of("ENROLLMENT_DATE:2021")); + dimensions.add(Set.of("ou:" + ou.getUid())); + dimensions.add(Set.of("pe:LAST_12_MONTHS")); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT).dimension(dimensions).build(); + + EventQueryParams params = subject.getFromRequest(request); + + List dimensionOrder = + params.getDimensions().stream().map(DimensionalObject::getDimension).toList(); + + assertEquals(List.of("pe", "ou"), dimensionOrder); + verify(dataQueryService, times(1)) + .getDimension( + eq("pe"), + eq(List.of("2021:ENROLLMENT_DATE", "LAST_12_MONTHS")), + any(), + anyList(), + anyBoolean(), + any()); + } + + @Test + void getFromRequestMergesPeInputsInFiltersUsingNormalizedFlow() { + BaseDimensionalObject peFilter = + new BaseDimensionalObject("pe", DimensionType.PERIOD, List.of()); + + when(dataQueryService.getDimension( + eq("pe"), + eq(List.of("2021:ENROLLMENT_DATE", "LAST_12_MONTHS")), + any(), + anyList(), + anyBoolean(), + any(), + any(IdScheme.class))) + .thenReturn(peFilter); + + Set> filters = new LinkedHashSet<>(); + filters.add(Set.of("ENROLLMENT_DATE:2021")); + filters.add(Set.of("pe:LAST_12_MONTHS")); + + EventDataQueryRequest request = baseRequestBuilder(AGGREGATE, EVENT).filter(filters).build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertEquals(1, params.getFilters().size()); + assertEquals("pe", params.getFilters().get(0).getDimension()); + verify(dataQueryService, times(1)) + .getDimension( + eq("pe"), + eq(List.of("2021:ENROLLMENT_DATE", "LAST_12_MONTHS")), + any(), + anyList(), + anyBoolean(), + any(), + any(IdScheme.class)); + } + + @Test + void getFromRequestResolvesEnrollmentOuAsDimension() { + 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("ENROLLMENT_OU:" + ouA.getUid() + ";" + ouB.getUid()))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertTrue(params.hasEnrollmentOuDimension()); + assertEquals(2, params.getEnrollmentOuDimensionItems().size()); + assertTrue(params.getEnrollmentOuDimensionLevels().isEmpty()); + } + + @Test + void getFromRequestResolvesEnrollmentOuAsFilter() { + 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(), + any(IdScheme.class))) + .thenReturn(ouDimension); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .filter(Set.of(Set.of("ENROLLMENT_OU:" + ouA.getUid()))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertTrue(params.hasEnrollmentOuFilter()); + assertFalse(params.hasEnrollmentOuDimension()); + assertEquals(1, params.getEnrollmentOuFilterItems().size()); + assertTrue(params.getEnrollmentOuFilterLevels().isEmpty()); + } + + @Test + void getFromRequestResolvesEnrollmentOuLevelAsDimensionLevelConstraint() { + when(organisationUnitService.getOrganisationUnitLevelByLevelOrUid("m9lBJogzE95")).thenReturn(4); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .dimension(Set.of(Set.of("ENROLLMENT_OU:LEVEL-m9lBJogzE95"))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertTrue(params.hasEnrollmentOuDimension()); + assertTrue(params.getEnrollmentOuDimensionItems().isEmpty()); + assertEquals(Set.of(4), params.getEnrollmentOuDimensionLevels()); + } + + @Test + void getFromRequestResolvesMixedEnrollmentOuLevelAndUidAsFilter() { + OrganisationUnit ouA = createOrganisationUnit('B'); + BaseDimensionalObject ouDimension = + new BaseDimensionalObject("ou", DimensionType.ORGANISATION_UNIT, List.of(ouA)); + + when(organisationUnitService.getOrganisationUnitLevelByLevelOrUid("m9lBJogzE95")).thenReturn(4); + when(dataQueryService.getDimension( + eq("ou"), + eq(List.of(ouA.getUid())), + any(), + anyList(), + anyBoolean(), + any(), + any(IdScheme.class))) + .thenReturn(ouDimension); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT) + .filter(Set.of(Set.of("ENROLLMENT_OU:" + ouA.getUid() + ";LEVEL-m9lBJogzE95"))) + .build(); + + EventQueryParams params = subject.getFromRequest(request); + + assertTrue(params.hasEnrollmentOuFilter()); + assertEquals(1, params.getEnrollmentOuFilterItems().size()); + assertEquals(Set.of(4), params.getEnrollmentOuFilterLevels()); + } + + @Test + void getFromRequestMergesMultiplePeriodDimensions() { + BaseDimensionalObject peDimension = + new BaseDimensionalObject("pe", DimensionType.PERIOD, List.of()); + + when(dataQueryService.getDimension( + eq("pe"), + eq(List.of("LAST_12_MONTHS", "2021:ENROLLMENT_DATE", "2022")), + any(), + anyList(), + anyBoolean(), + any())) + .thenReturn(peDimension); + + Set> dimensions = new LinkedHashSet<>(); + dimensions.add(Set.of("pe:LAST_12_MONTHS")); + dimensions.add(Set.of("ENROLLMENT_DATE:2021")); + dimensions.add(Set.of("pe:2022")); + + EventDataQueryRequest request = + baseRequestBuilder(AGGREGATE, EVENT).dimension(dimensions).build(); + + EventQueryParams params = subject.getFromRequest(request); + + List dimensionOrder = + params.getDimensions().stream().map(DimensionalObject::getDimension).toList(); + + assertEquals(List.of("pe"), dimensionOrder); + verify(dataQueryService, times(1)) + .getDimension( + eq("pe"), + eq(List.of("LAST_12_MONTHS", "2021:ENROLLMENT_DATE", "2022")), + any(), + anyList(), + anyBoolean(), + any()); + } + private EventDataQueryRequest.EventDataQueryRequestBuilder baseRequestBuilder( org.hisp.dhis.common.RequestTypeAware.EndpointAction action, org.hisp.dhis.common.RequestTypeAware.EndpointItem item) { 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 new file mode 100644 index 000000000000..d7ee6f3145f9 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EventAggregateServiceTest.java @@ -0,0 +1,154 @@ +/* + * 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; + +import static org.hisp.dhis.analytics.DataQueryParams.VALUE_ID; +import static org.hisp.dhis.common.DimensionConstants.PERIOD_DIM_ID; +import static org.hisp.dhis.common.DimensionType.PERIOD; +import static org.hisp.dhis.test.TestBase.createOrganisationUnit; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Method; +import java.util.List; +import org.hisp.dhis.analytics.common.ColumnHeader; +import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.common.BaseDimensionalObject; +import org.hisp.dhis.common.DimensionalItemObject; +import org.hisp.dhis.common.Grid; +import org.hisp.dhis.common.GridHeader; +import org.hisp.dhis.period.PeriodDimension; +import org.hisp.dhis.program.Program; +import org.hisp.dhis.system.grid.ListGrid; +import org.junit.jupiter.api.Test; + +class EventAggregateServiceTest { + + private final EventAggregateService service = + new EventAggregateService(null, null, null, null, null, null, null, null, null, null, null); + + @Test + void shouldUseEnrollmentDateHeaderForStaticPeriodDateField() throws Exception { + Program program = new Program(); + program.setEnrollmentDateLabel("Start of treatment date"); + + GridHeader header = + invokeAddDimensionHeaders(singleDateFieldParams("ENROLLMENT_DATE", program)); + + assertEquals("enrollmentdate", header.getName()); + assertEquals("Start of treatment date", header.getColumn()); + } + + @Test + void shouldUseIncidentDateHeaderForStaticPeriodDateField() throws Exception { + Program program = new Program(); + program.setIncidentDateLabel("Incident date custom"); + + GridHeader header = invokeAddDimensionHeaders(singleDateFieldParams("INCIDENT_DATE", program)); + + assertEquals("incidentdate", header.getName()); + assertEquals("Incident date custom", header.getColumn()); + } + + @Test + void shouldKeepPeHeaderForDefaultPeriod() throws Exception { + GridHeader header = invokeAddDimensionHeaders(defaultPeriodParams()); + + assertEquals("pe", header.getName()); + assertEquals("Period", header.getColumn()); + } + + @Test + void shouldFallbackToPeHeaderWhenPeriodDateFieldsAreMixed() throws Exception { + GridHeader header = invokeAddDimensionHeaders(mixedPeriodDateFieldsParams()); + + assertEquals("pe", header.getName()); + assertEquals("Period", header.getColumn()); + } + + @Test + void shouldKeepValueAndEnrollmentOuHeadersUnchanged() throws Exception { + EventQueryParams params = + new EventQueryParams.Builder(defaultPeriodParams()) + .withEnrollmentOuDimension(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.ENROLLMENT_OU.getItem(), headers.get(1).getName()); + assertEquals(VALUE_ID, headers.get(2).getName()); + } + + private GridHeader invokeAddDimensionHeaders(EventQueryParams params) throws Exception { + Grid grid = new ListGrid(); + invokePrivate("addDimensionHeaders", EventQueryParams.class, Grid.class, params, grid); + return grid.getHeaders().get(0); + } + + private void invokePrivate( + String methodName, Class arg0Type, Class arg1Type, Object arg0, Object arg1) + throws Exception { + Method method = EventAggregateService.class.getDeclaredMethod(methodName, arg0Type, arg1Type); + method.setAccessible(true); + method.invoke(service, arg0, arg1); + } + + private EventQueryParams singleDateFieldParams(String dateField, Program program) { + PeriodDimension period = PeriodDimension.of("2021").setDateField(dateField); + BaseDimensionalObject periodDimension = + new BaseDimensionalObject(PERIOD_DIM_ID, PERIOD, "Period", List.of(period)); + + return new EventQueryParams.Builder() + .addDimension(periodDimension) + .withProgram(program) + .build(); + } + + private EventQueryParams defaultPeriodParams() { + PeriodDimension period = PeriodDimension.of("2021"); + BaseDimensionalObject periodDimension = + new BaseDimensionalObject(PERIOD_DIM_ID, PERIOD, "Period", List.of(period)); + + return new EventQueryParams.Builder().addDimension(periodDimension).build(); + } + + private EventQueryParams mixedPeriodDateFieldsParams() { + PeriodDimension enrollmentDatePeriod = + PeriodDimension.of("2021").setDateField("ENROLLMENT_DATE"); + PeriodDimension defaultPeriod = PeriodDimension.of("2022"); + BaseDimensionalObject periodDimension = + new BaseDimensionalObject( + PERIOD_DIM_ID, PERIOD, "Period", List.of(enrollmentDatePeriod, defaultPeriod)); + + return new EventQueryParams.Builder().addDimension(periodDimension).build(); + } +} 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 3690728f727e..5155acdd2664 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 @@ -149,6 +149,33 @@ void validateValidTimeField() { eventQueryValidator.validate(params); } + @Test + void validateSuccessWithEnrollmentOuOnly() { + 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()) + .withEnrollmentOuFilter(List.of(ouA)) + .build(); + + assertNull(eventQueryValidator.validateForErrorMessage(params)); + } + + @Test + void validateFailsWithoutOuAndWithoutEnrollmentOu() { + 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()) + .build(); + + ErrorMessage error = eventQueryValidator.validateForErrorMessage(params); + + assertEquals(ErrorCode.E7200, error.getErrorCode()); + } + @Test void validateSingleDataElementMultipleProgramsQueryItemSuccess() { EventQueryParams params = diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/enrollmentou/OrgUnitSqlCoordinatorTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/enrollmentou/OrgUnitSqlCoordinatorTest.java new file mode 100644 index 000000000000..29ba816e801d --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/enrollmentou/OrgUnitSqlCoordinatorTest.java @@ -0,0 +1,171 @@ +/* + * 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.enrollmentou; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hisp.dhis.test.TestBase.createOrganisationUnit; +import static org.hisp.dhis.test.TestBase.createProgram; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.analytics.event.data.ou.OrgUnitSqlCoordinator; +import org.hisp.dhis.analytics.util.sql.SelectBuilder; +import org.hisp.dhis.commons.util.SqlHelper; +import org.hisp.dhis.db.sql.AnalyticsSqlBuilder; +import org.hisp.dhis.db.sql.PostgreSqlAnalyticsSqlBuilder; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.program.AnalyticsType; +import org.junit.jupiter.api.Test; + +class OrgUnitSqlCoordinatorTest { + + private final AnalyticsSqlBuilder sqlBuilder = new PostgreSqlAnalyticsSqlBuilder(); + + @Test + void testAddJoinIfNeededNoOpWhenNoEnrollmentOu() { + EventQueryParams params = + new EventQueryParams.Builder().withProgram(createProgram('A')).build(); + SelectBuilder sb = new SelectBuilder(); + sb.from("analytics_event_test", "ax"); + + OrgUnitSqlCoordinator.addJoinIfNeeded(sb, params); + + assertThat(sb.build(), not(containsString("analytics_rs_orgunitstructure"))); + } + + @Test + void testAddJoinIfNeededWhenEnrollmentOuPresent() { + OrganisationUnit ouA = createOrganisationUnit('A'); + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(createProgram('A')) + .withEnrollmentOuFilter(List.of(ouA)) + .build(); + SelectBuilder sb = new SelectBuilder(); + sb.from("analytics_event_test", "ax"); + + OrgUnitSqlCoordinator.addJoinIfNeeded(sb, params); + + assertThat(sb.build(), containsString("analytics_rs_orgunitstructure")); + assertThat(sb.build(), containsString("ax.\"enrollmentou\" = ous.\"organisationunituid\"")); + } + + @Test + void testAddDimensionSelectColumnsAggregateEventOnly() { + OrganisationUnit ouA = createOrganisationUnit('A'); + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(createProgram('A')) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + List columns = new ArrayList<>(); + + OrgUnitSqlCoordinator.addDimensionSelectColumns( + columns, params, false, true, AnalyticsType.EVENT); + + assertThat(columns, hasSize(1)); + assertThat(columns.get(0), is("ous.\"organisationunituid\" as enrollmentou")); + } + + @Test + void testAddDimensionSelectColumnsNoOpWhenNotEventAggregate() { + OrganisationUnit ouA = createOrganisationUnit('A'); + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(createProgram('A')) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + List columns = new ArrayList<>(); + + OrgUnitSqlCoordinator.addDimensionSelectColumns( + columns, params, false, false, AnalyticsType.EVENT); + OrgUnitSqlCoordinator.addDimensionSelectColumns( + columns, params, false, true, AnalyticsType.ENROLLMENT); + + assertThat(columns, hasSize(0)); + } + + @Test + void testAddQuerySelectColumnsWhenEnrollmentOuDimension() { + OrganisationUnit ouA = createOrganisationUnit('A'); + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(createProgram('A')) + .withEnrollmentOuDimension(List.of(ouA)) + .build(); + List columns = new ArrayList<>(); + + OrgUnitSqlCoordinator.addQuerySelectColumns(columns, params); + + assertThat(columns, hasSize(2)); + assertThat(columns.get(0), is("ous.\"organisationunituid\" as enrollmentou")); + assertThat(columns.get(1), is("ous.\"name\" as enrollmentouname")); + } + + @Test + void testAppendWherePredicateIfNeededNoOpWhenNoEnrollmentOu() { + EventQueryParams params = + new EventQueryParams.Builder().withProgram(createProgram('A')).build(); + StringBuilder sql = new StringBuilder(); + + OrgUnitSqlCoordinator.appendWherePredicateIfNeeded(sql, new SqlHelper(), params, sqlBuilder); + + assertThat(sql.toString(), is("")); + } + + @Test + void testAppendWherePredicateIfNeededWithUidAndLevelConstraints() { + OrganisationUnit ouA = createOrganisationUnit('A'); + OrganisationUnit ouB = createOrganisationUnit('B'); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(createProgram('A')) + .withEnrollmentOuFilter(List.of(ouA, ouB)) + .withEnrollmentOuFilterLevels(new LinkedHashSet<>(List.of(2, 4))) + .build(); + + StringBuilder sql = new StringBuilder(); + OrgUnitSqlCoordinator.appendWherePredicateIfNeeded(sql, new SqlHelper(), params, sqlBuilder); + + String where = sql.toString(); + assertThat(where, containsString("where (")); + assertThat(where, containsString("ous.\"organisationunituid\" in ('" + ouA.getUid() + "'")); + assertThat(where, containsString("'" + ouB.getUid() + "')")); + assertThat(where, containsString("ous.\"level\" in (2,4)")); + assertThat(where, containsString(" or ")); + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/enrollmentou/OrgUnitSqlFragmentsTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/enrollmentou/OrgUnitSqlFragmentsTest.java new file mode 100644 index 000000000000..5e0ead8a9180 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/enrollmentou/OrgUnitSqlFragmentsTest.java @@ -0,0 +1,88 @@ +/* + * 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.enrollmentou; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hisp.dhis.analytics.AnalyticsConstants.ANALYTICS_TBL_ALIAS; +import static org.hisp.dhis.analytics.AnalyticsConstants.ORG_UNIT_STRUCT_ALIAS; +import static org.hisp.dhis.analytics.common.ColumnHeader.ENROLLMENT_OU_NAME; +import static org.hisp.dhis.analytics.table.EventAnalyticsColumnName.ENROLLMENT_OU_COLUMN_NAME; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.hisp.dhis.analytics.event.data.ou.OrgUnitSqlConstants; +import org.hisp.dhis.analytics.event.data.ou.OrgUnitSqlFragments; +import org.junit.jupiter.api.Test; + +class OrgUnitSqlFragmentsTest { + + @Test + void testJoinCondition() { + assertThat( + OrgUnitSqlFragments.joinCondition("ous"), + is("ax.\"enrollmentou\" = ous.\"organisationunituid\"")); + } + + @Test + void testInnerJoinClause() { + assertEquals( + "inner join analytics_rs_orgunitstructure as ous on ax.\"enrollmentou\" = ous.\"organisationunituid\" ", + OrgUnitSqlFragments.innerJoinClause()); + } + + @Test + void testSelectEnrollmentOuUid() { + assertEquals( + "ous.\"organisationunituid\" as enrollmentou", + OrgUnitSqlFragments.selectEnrollmentOuUid(false)); + assertEquals("ous.\"organisationunituid\"", OrgUnitSqlFragments.selectEnrollmentOuUid(true)); + } + + @Test + void testSelectEnrollmentOuName() { + assertEquals("ous.\"name\" as enrollmentouname", OrgUnitSqlFragments.selectEnrollmentOuName()); + } + + @Test + void testPredicates() { + assertEquals( + " ous.\"organisationunituid\" in ('a','b') ", + OrgUnitSqlFragments.predicateByUids("'a','b'")); + assertEquals(" ous.\"level\" in (2,4) ", OrgUnitSqlFragments.predicateByLevels("2,4")); + } + + @Test + void testConstantsAreMappedFromSharedIdentifiers() { + assertEquals(ANALYTICS_TBL_ALIAS, OrgUnitSqlConstants.EVENT_TABLE_ALIAS); + assertEquals(ORG_UNIT_STRUCT_ALIAS, OrgUnitSqlConstants.ORG_UNIT_STRUCTURE_ALIAS); + assertEquals(ENROLLMENT_OU_COLUMN_NAME, OrgUnitSqlConstants.EVENT_ENROLLMENT_OU_COLUMN); + assertEquals(OrgUnitSqlConstants.ENROLLMENT_OU_NAME_RESULT_ALIAS, ENROLLMENT_OU_NAME.getItem()); + } +} 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 new file mode 100644 index 000000000000..6d8374f562ab --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/security/DefaultAnalyticsSecurityManagerTest.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2004-2022, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.analytics.security; + +import static org.hisp.dhis.common.DimensionConstants.ORGUNIT_DIM_ID; +import static org.junit.jupiter.api.Assertions.assertEquals; +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.dataapproval.DataApprovalLevelService; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.security.acl.AclService; +import org.hisp.dhis.setting.SystemSettingsProvider; +import org.hisp.dhis.test.TestBase; +import org.hisp.dhis.user.CurrentUserUtil; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DefaultAnalyticsSecurityManagerTest extends TestBase { + + @Mock private DataApprovalLevelService approvalLevelService; + @Mock private SystemSettingsProvider settingsProvider; + @Mock private DimensionService dimensionService; + @Mock private AclService aclService; + @Mock private UserService userService; + @Mock private User currentUser; + + @InjectMocks private DefaultAnalyticsSecurityManager securityManager; + + private OrganisationUnit ouA; + private OrganisationUnit ouB; + + @BeforeEach + 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(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(); + CurrentUserUtil.injectUserInSecurityContext(currentUserDetails); + } + + @AfterEach + void tearDown() { + CurrentUserUtil.clearSecurityContext(); + } + + @Test + void shouldNotAssignDefaultOuConstraintWhenEnrollmentOuIsUsedWithoutExplicitOu() { + EventQueryParams params = + new EventQueryParams.Builder().withEnrollmentOuFilter(List.of(ouA)).build(); + + EventQueryParams constrained = securityManager.withUserConstraints(params); + + assertTrue(constrained.getDimensionOrFilterItems(ORGUNIT_DIM_ID).isEmpty()); + assertTrue(constrained.hasEnrollmentOu()); + } + + @Test + void shouldKeepExplicitOuWhenEnrollmentOuIsAlsoUsed() { + EventQueryParams params = + new EventQueryParams.Builder() + .withOrganisationUnits(List.of(ouA)) + .withEnrollmentOuFilter(List.of(ouB)) + .build(); + + EventQueryParams constrained = securityManager.withUserConstraints(params); + List constrainedOus = + constrained.getDimensionOrFilterItems(ORGUNIT_DIM_ID); + + assertEquals(1, constrainedOus.size()); + assertEquals(ouA.getUid(), constrainedOus.get(0).getUid()); + } + + @Test + void shouldAssignDefaultOuConstraintWhenNoOuAndNoEnrollmentOuAreProvided() { + EventQueryParams params = new EventQueryParams.Builder().build(); + + EventQueryParams constrained = securityManager.withUserConstraints(params); + List constrainedOus = + constrained.getDimensionOrFilterItems(ORGUNIT_DIM_ID); + + assertEquals(1, constrainedOus.size()); + assertEquals(ouB.getUid(), constrainedOus.get(0).getUid()); + } +} 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 aec203d1b138..5b49323718c1 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 @@ -86,6 +86,7 @@ import org.hisp.dhis.option.Option; import org.hisp.dhis.option.OptionSet; import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.period.PeriodDimension; import org.hisp.dhis.program.Program; import org.hisp.dhis.program.ProgramStage; import org.hisp.dhis.system.grid.ListGrid; @@ -291,6 +292,274 @@ void shouldIncludePeriodDimensionItems() { assertEquals(2, dimensions.get(PERIOD_DIM_ID).size()); } + @Test + @DisplayName("should emit enrollment date periods under enrollmentdate key and pe") + void shouldEmitEnrollmentDatePeriodsUnderSeparateKey() { + // Given + Grid grid = new ListGrid(); + + // Create periods with ENROLLMENT_DATE dateField (simulating what + // normalizeStaticDateDimension produces when dimension=ENROLLMENT_DATE:2023Q1) + List enrollmentPeriods = + createPeriodDimensions("2023Q1").stream() + .map(pd -> pd.setDateField("ENROLLMENT_DATE")) + .toList(); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withPeriods(enrollmentPeriods, "quarterly") + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertNotNull(dimensions); + + // Enrollment date periods should be under "enrollmentdate" key + assertTrue( + dimensions.containsKey("enrollmentdate"), + "Dimensions should contain 'enrollmentdate' key for ENROLLMENT_DATE periods"); + assertEquals(1, dimensions.get("enrollmentdate").size()); + + // pe should remain populated for backwards-compatible clients + List pePeriods = dimensions.getOrDefault(PERIOD_DIM_ID, List.of()); + assertEquals(1, pePeriods.size(), "pe dimension should contain ENROLLMENT_DATE periods"); + + // Metadata items should contain an entry for "enrollmentdate" + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertNotNull(items); + assertTrue( + items.containsKey("enrollmentdate"), + "Items should contain 'enrollmentdate' metadata entry"); + MetadataItem enrollmentDateItem = (MetadataItem) items.get("enrollmentdate"); + assertEquals("DateOfEnrollmentDescription", enrollmentDateItem.getName()); + } + + @Test + @DisplayName("should use program custom enrollment date label in enrollmentdate metadata item") + void shouldUseCustomEnrollmentDateLabel() { + // Given + Grid grid = new ListGrid(); + + Program programWithCustomLabel = createProgram('C', null, orgUnitA); + programWithCustomLabel.setEnrollmentDateLabel("Date of Registration"); + + List enrollmentPeriods = + createPeriodDimensions("2023Q1").stream() + .map(pd -> pd.setDateField("ENROLLMENT_DATE")) + .toList(); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programWithCustomLabel) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withPeriods(enrollmentPeriods, "quarterly") + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertNotNull(items); + assertTrue(items.containsKey("enrollmentdate")); + MetadataItem enrollmentDateItem = (MetadataItem) items.get("enrollmentdate"); + assertEquals("Date of Registration", enrollmentDateItem.getName()); + } + + @Test + @DisplayName("should use program custom incident date label in incidentdate metadata item") + void shouldUseCustomIncidentDateLabel() { + // Given + Grid grid = new ListGrid(); + + Program programWithCustomLabel = createProgram('D', null, orgUnitA); + programWithCustomLabel.setIncidentDateLabel("Date of Symptom Onset"); + + List incidentPeriods = + createPeriodDimensions("2023Q1").stream() + .map(pd -> pd.setDateField("INCIDENT_DATE")) + .toList(); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programWithCustomLabel) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withPeriods(incidentPeriods, "quarterly") + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertNotNull(items); + assertTrue(items.containsKey("incidentdate")); + MetadataItem incidentDateItem = (MetadataItem) items.get("incidentdate"); + assertEquals("Date of Symptom Onset", incidentDateItem.getName()); + } + + @Test + @DisplayName( + "should emit enrollmentdate in items and dimensions after periods consumed and re-added") + void shouldEmitEnrollmentDateMetadataAfterPeriodsConsumedAndReAdded() { + // Given - simulate the query path where withStartEndDatesForPeriods() converts + // period dimensions into timeDateRanges and removes the period dimension, + // then periods are re-added for metadata generation + Grid grid = new ListGrid(); + + List enrollmentPeriods = + createPeriodDimensions("2023Q1").stream() + .map(pd -> pd.setDateField("ENROLLMENT_DATE")) + .toList(); + + // Build with periods, consume them via withStartEndDatesForPeriods(), + // then re-add periods (simulating the retain-and-re-add pattern) + EventQueryParams initialParams = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(QUERY) + .withOrganisationUnits(List.of(orgUnitA)) + .withPeriods(enrollmentPeriods, "quarterly") + .build(); + + EventQueryParams params = + new EventQueryParams.Builder(initialParams) + .withStartEndDatesForPeriods() + .withPeriods(enrollmentPeriods, "") + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then - enrollmentdate should appear in items + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertNotNull(items); + assertTrue( + items.containsKey("enrollmentdate"), + "Items should contain 'enrollmentdate' metadata entry"); + MetadataItem enrollmentDateItem = (MetadataItem) items.get("enrollmentdate"); + assertEquals("DateOfEnrollmentDescription", enrollmentDateItem.getName()); + + // Dimensions should contain enrollmentdate with period UIDs + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertNotNull(dimensions); + assertTrue( + dimensions.containsKey("enrollmentdate"), + "Dimensions should contain 'enrollmentdate' key"); + assertEquals(1, dimensions.get("enrollmentdate").size()); + assertTrue( + dimensions.getOrDefault(PERIOD_DIM_ID, List.of()).isEmpty(), + "Query path should keep legacy behavior with empty pe dimension"); + } + + @Test + @DisplayName("should emit incident date periods under incidentdate key and pe") + void shouldEmitIncidentDatePeriodsUnderSeparateKey() { + // Given + Grid grid = new ListGrid(); + + List incidentPeriods = + createPeriodDimensions("2023Q1").stream() + .map(pd -> pd.setDateField("INCIDENT_DATE")) + .toList(); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withPeriods(incidentPeriods, "quarterly") + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertNotNull(dimensions); + + assertTrue( + dimensions.containsKey("incidentdate"), + "Dimensions should contain 'incidentdate' key for INCIDENT_DATE periods"); + assertEquals(1, dimensions.get("incidentdate").size()); + + List pePeriods = dimensions.getOrDefault(PERIOD_DIM_ID, List.of()); + assertEquals(1, pePeriods.size(), "pe dimension should contain INCIDENT_DATE periods"); + } + + @Test + @DisplayName("should separate mixed periods into pe and date-specific keys") + void shouldSeparateMixedPeriodsIntoPeAndDateSpecificKeys() { + // Given + Grid grid = new ListGrid(); + + // Mix of regular period and enrollment date period + PeriodDimension regularPeriod = createPeriodDimensions("2023Q1").get(0); + PeriodDimension enrollmentPeriod = + createPeriodDimensions("2023Q2").get(0).setDateField("ENROLLMENT_DATE"); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withPeriods(List.of(regularPeriod, enrollmentPeriod), "quarterly") + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertNotNull(dimensions); + + // pe contains all periods + assertTrue(dimensions.containsKey(PERIOD_DIM_ID)); + assertEquals(2, dimensions.get(PERIOD_DIM_ID).size()); + + // Enrollment date period under enrollmentdate + assertTrue(dimensions.containsKey("enrollmentdate")); + assertEquals(1, dimensions.get("enrollmentdate").size()); + } + @Test @DisplayName("should include organisation unit dimension items in metadata") void shouldIncludeOrgUnitDimensionItems() { @@ -1356,6 +1625,46 @@ void shouldIgnoreDimensionValuesForNonDateItems() { } } + @Nested + @DisplayName("Enrollment OU Dimension Tests") + class EnrollmentOuDimensionTests { + + @Test + @DisplayName("should include enrollment OU dimension items and metadata") + void shouldIncludeEnrollmentOuDimensionItemsAndMetadata() { + Grid grid = new ListGrid(); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withPeriods(createPeriodDimensions("2023Q1"), "quarterly") + .withEnrollmentOuDimension(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("enrollmentou")); + assertEquals(2, dimensions.get("enrollmentou").size()); + assertTrue(dimensions.get("enrollmentou").contains(orgUnitA.getUid())); + assertTrue(dimensions.get("enrollmentou").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())); + } + } + @Nested @DisplayName("Custom Header Tests") class CustomHeaderTests { 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 bb7ad084446f..918e2dc5cbac 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 @@ -63,6 +63,8 @@ public enum ColumnHeader { EXTENT("extent", "Extent"), POINTS("points", "Points"), EVENT_STATUS("eventstatus", "Event status"), + ENROLLMENT_OU("enrollmentou", "Enrollment org unit"), + ENROLLMENT_OU_NAME("enrollmentouname", "Enrollment 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/event/aggregate/EventsAggregate11AutoTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate11AutoTest.java index 6ba83c1552fd..22a7ce11dfcc 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate11AutoTest.java +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate11AutoTest.java @@ -979,4 +979,199 @@ public void stageAndOuUserOrgUnitAndEvent() throws JSONException { actualHeaders, Map.of("ZkbAXlQUYJG.ou", "ImspTQPwCqd", "ZkbAXlQUYJG.eventdate", "2021", "value", "12")); } + + @DisplayName("Events Aggregate - ENROLLMENT_DATE dimension with period filter") + @Test + public void enrollmentDate() 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=ENROLLMENT_DATE:2021"); + + // When + ApiResponse response = actions.aggregate().get("ur1Edk5Oe2n", 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, + 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\"},\"EPEcjy3FWmI\":{\"name\":\"Lab monitoring\"},\"pe\":{},\"ur1Edk5Oe2n\":{\"name\":\"TB program\"},\"ou\":{},\"jdRD35YwbRH\":{\"name\":\"Sputum smear microscopy test\"},\"2021\":{\"name\":\"2021\"},\"ZkbAXlQUYJG\":{\"name\":\"TB visit\"},\"enrollmentdate\":{\"name\":\"Start of treatment date\"}},\"dimensions\":{\"pe\":[\"2021\"],\"ou\":[\"ImspTQPwCqd\"],\"enrollmentdate\":[\"2021\"]}}"; + 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, + "enrollmentdate", + "Start of treatment date", + "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("enrollmentdate", "2022", "value", "18")); + + // Validate row exists with values from original row index 2 + validateRowExists(response, actualHeaders, Map.of("enrollmentdate", "2021", "value", "8")); + } + + @DisplayName("Events Aggregate - INCIDENT_DATE dimension with period filter") + @Test + public void incidentDate() 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=INCIDENT_DATE:2021"); + + // When + ApiResponse response = actions.aggregate().get("ur1Edk5Oe2n", 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, + 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\"},\"EPEcjy3FWmI\":{\"name\":\"Lab monitoring\"},\"pe\":{},\"ur1Edk5Oe2n\":{\"name\":\"TB program\"},\"ou\":{},\"jdRD35YwbRH\":{\"name\":\"Sputum smear microscopy test\"},\"2021\":{\"name\":\"2021\"},\"ZkbAXlQUYJG\":{\"name\":\"TB visit\"},\"incidentdate\":{\"name\":\"Start of treatment date\"}},\"dimensions\":{\"pe\":[\"2021\"],\"ou\":[\"ImspTQPwCqd\"],\"incidentdate\":[\"2021\"]}}"; + 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, + "incidentdate", + "Start of treatment date", + "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("incidentdate", "2022", "value", "18")); + + // Validate row exists with values from original row index 2 + validateRowExists(response, actualHeaders, Map.of("incidentdate", "2021", "value", "8")); + } + + @DisplayName( + "Events Aggregate - ENROLLMENT_OU dimension with multiple org units and period filter") + @Test + public void enrollmentOuWithMultipleOus() 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=ENROLLMENT_OU:BXd3TqaAxkK;VpYAl8dXs6m;uFp0ztDOFbI,pe:2021"); + + // When + ApiResponse response = actions.aggregate().get("IpHINAT79UW", 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\":{\"pe\":{\"name\":\"Period\"},\"IpHINAT79UW\":{\"name\":\"Child Programme\"},\"ZzYYXq4fJie\":{\"name\":\"Baby Postnatal\"},\"uFp0ztDOFbI\":{\"name\":\"Bendu CHC\"},\"A03MvHHogjR\":{\"name\":\"Birth\"},\"BXd3TqaAxkK\":{\"name\":\"Sahun (Bumpeh) MCHP\"},\"2021\":{\"name\":\"2021\"},\"VpYAl8dXs6m\":{\"name\":\"Bendoma (Malegohun) MCHP\"}},\"dimensions\":{\"enrollmentou\":[\"BXd3TqaAxkK\",\"VpYAl8dXs6m\",\"uFp0ztDOFbI\"],\"pe\":[\"2021\"]}}"; + 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, + "enrollmentou", + "Enrollment 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", "2021", "enrollmentou", "BXd3TqaAxkK", "value", "33")); + + // Validate row exists with values from original row index 2 + validateRowExists( + response, + actualHeaders, + Map.of("pe", "2021", "enrollmentou", "VpYAl8dXs6m", "value", "36")); + } } diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery6AutoTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery6AutoTest.java index 07007c991fb7..2ed25fa357fd 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery6AutoTest.java +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery6AutoTest.java @@ -819,7 +819,7 @@ public void stageAndEventDateRange() throws JSONException { // 3. Assert metaData. String expectedMetaData = - "{\"pager\":{\"total\":13744,\"pageCount\":138,\"pageSize\":100,\"page\":1},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"eBAyeGv0exc\":{\"name\":\"Inpatient morbidity and mortality\"},\"ou\":{},\"Zj7UnCAulEk.eventdate\":{\"name\":\"Report date\"},\"Zj7UnCAulEk\":{\"name\":\"Inpatient morbidity and mortality\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"Zj7UnCAulEk.eventdate\":[]}}"; + "{\"pager\":{\"total\":13744,\"pageCount\":138,\"pageSize\":100,\"page\":1},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"eBAyeGv0exc\":{\"name\":\"Inpatient morbidity and mortality\"},\"ou\":{},\"Zj7UnCAulEk.eventdate\":{\"name\":\"Report date\"},\"Zj7UnCAulEk\":{\"name\":\"Inpatient morbidity and mortality\"}},\"dimensions\":{\"ou\":[\"ImspTQPwCqd\"],\"Zj7UnCAulEk.eventdate\":[]}}"; String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); assertEquals(expectedMetaData, actualMetaData, false); @@ -1238,7 +1238,7 @@ public void stageAndEventLowerThan() throws JSONException { // 3. Assert metaData. String expectedMetaData = - "{\"pager\":{\"total\":107790,\"pageCount\":1078,\"pageSize\":100,\"page\":1},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"eBAyeGv0exc\":{\"name\":\"Inpatient morbidity and mortality\"},\"ou\":{},\"Zj7UnCAulEk.eventdate\":{\"name\":\"Report date\"},\"Zj7UnCAulEk\":{\"name\":\"Inpatient morbidity and mortality\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"Zj7UnCAulEk.eventdate\":[]}}"; + "{\"pager\":{\"total\":107790,\"pageCount\":1078,\"pageSize\":100,\"page\":1},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"eBAyeGv0exc\":{\"name\":\"Inpatient morbidity and mortality\"},\"ou\":{},\"Zj7UnCAulEk.eventdate\":{\"name\":\"Report date\"},\"Zj7UnCAulEk\":{\"name\":\"Inpatient morbidity and mortality\"}},\"dimensions\":{\"ou\":[\"ImspTQPwCqd\"],\"Zj7UnCAulEk.eventdate\":[]}}"; String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); assertEquals(expectedMetaData, actualMetaData, false); @@ -1756,6 +1756,7 @@ public void stageAndOuUserOrgUnit() throws JSONException { String expectedMetaData = "{\"pager\":{\"total\":1,\"pageCount\":1,\"pageSize\":100,\"page\":1},\"items\":{\"ImspTQPwCqd\":{\"code\":\"OU_525\",\"name\":\"Sierra Leone\"},\"EPEcjy3FWmI\":{\"name\":\"Lab monitoring\"},\"ur1Edk5Oe2n\":{\"name\":\"TB program\"},\"USER_ORGUNIT\":{\"organisationUnits\":[\"ImspTQPwCqd\"]},\"jdRD35YwbRH\":{\"name\":\"Sputum smear microscopy test\"},\"ZkbAXlQUYJG.ou\":{\"name\":\"Organisation unit\"},\"202206\":{\"name\":\"June 2022\"},\"ZkbAXlQUYJG\":{\"name\":\"TB visit\"}},\"dimensions\":{\"pe\":[],\"ZkbAXlQUYJG.ou\":[\"ImspTQPwCqd\"]}}\n"; String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + System.out.println(expectedMetaData); assertEquals(expectedMetaData, actualMetaData, false); // 4. Validate Headers By Name (conditionally checking PostGIS headers). @@ -2818,4 +2819,436 @@ public void stageAndCategoryOptionGroupSet() throws JSONException { validateRowValueByName(response, actualHeaders, 9, "kO3z4Dhc038.C31vHZqu0qU", "ddAo6zmIHOk"); validateRowValueByName(response, actualHeaders, 9, "oucode", "OU_260419"); } + + @Test + public void enrollmentDate() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("asc=eventdate") + .add("headers=oucode,enrollmentdate") + .add("displayProperty=NAME") + .add("pageSize=10") + .add("page=1") + .add("dimension=ENROLLMENT_DATE:2021") + .add("desc=eventdate,lastupdated"); + + // When + ApiResponse response = actions.query().get("ur1Edk5Oe2n", 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, + 10, + 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,\"total\":27,\"pageSize\":10,\"pageCount\":3},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"EPEcjy3FWmI\":{\"name\":\"Lab monitoring\"},\"pe\":{},\"ur1Edk5Oe2n\":{\"name\":\"TB program\"},\"ou\":{},\"jdRD35YwbRH\":{\"name\":\"Sputum smear microscopy test\"},\"2021\":{\"name\":\"2021\"},\"ZkbAXlQUYJG\":{\"name\":\"TB visit\"},\"enrollmentdate\":{\"name\":\"Start of treatment date\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"enrollmentdate\":[\"2021\"]}}"; + 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, + "oucode", + "Organisation unit code", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "enrollmentdate", + "Start of treatment date", + "DATETIME", + "java.time.LocalDateTime", + 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, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 0, "enrollmentdate", "2021-11-11 12:27:48.386"); + + // Validate selected values for row index 3 + validateRowValueByName(response, actualHeaders, 3, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 3, "enrollmentdate", "2021-05-19 12:27:48.317"); + + // Validate selected values for row index 6 + validateRowValueByName(response, actualHeaders, 6, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 6, "enrollmentdate", "2021-09-11 12:27:48.552"); + + // Validate selected values for row index 9 + validateRowValueByName(response, actualHeaders, 9, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 9, "enrollmentdate", "2021-05-14 12:35:24.03"); + } + + @Test + public void enrollmentDateRelativePeriod() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("asc=eventdate") + .add("headers=oucode,enrollmentdate") + .add("displayProperty=NAME") + .add("pageSize=10") + .add("page=1") + .add("dimension=ENROLLMENT_DATE:LAST_6_MONTHS") + .add("relativePeriodDate=2021-11-11") + .add("desc=eventdate,lastupdated"); + + // When + ApiResponse response = actions.query().get("ur1Edk5Oe2n", 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, + 10, + 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,\"total\":21,\"pageSize\":10,\"pageCount\":3},\"items\":{\"ou\":{},\"jdRD35YwbRH\":{\"name\":\"Sputum smear microscopy test\"},\"202109\":{\"name\":\"September 2021\"},\"202107\":{\"name\":\"July 2021\"},\"202108\":{\"name\":\"August 2021\"},\"ZkbAXlQUYJG\":{\"name\":\"TB visit\"},\"202105\":{\"name\":\"May 2021\"},\"202106\":{\"name\":\"June 2021\"},\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"202110\":{\"name\":\"October 2021\"},\"LAST_6_MONTHS\":{\"name\":\"Last 6 months\"},\"EPEcjy3FWmI\":{\"name\":\"Lab monitoring\"},\"pe\":{},\"ur1Edk5Oe2n\":{\"name\":\"TB program\"},\"enrollmentdate\":{\"name\":\"Start of treatment date\"}},\"dimensions\":{\"ou\":[\"ImspTQPwCqd\"],\"enrollmentdate\":[\"202105\",\"202106\",\"202107\",\"202108\",\"202109\",\"202110\"]}}"; + 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, + "oucode", + "Organisation unit code", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "enrollmentdate", + "Start of treatment date", + "DATETIME", + "java.time.LocalDateTime", + 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, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 0, "enrollmentdate", "2021-05-19 12:27:48.317"); + + // Validate selected values for row index 3 + validateRowValueByName(response, actualHeaders, 3, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 3, "enrollmentdate", "2021-05-14 12:35:24.03"); + + // Validate selected values for row index 6 + validateRowValueByName(response, actualHeaders, 6, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 6, "enrollmentdate", "2021-10-15 12:34:17.849"); + + // Validate selected values for row index 9 + validateRowValueByName(response, actualHeaders, 9, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 9, "enrollmentdate", "2021-10-15 12:34:17.849"); + } + + @Test + public void enrollmentIncidentDateFixedYear() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("asc=eventdate") + .add("headers=oucode,enrollmentdate") + .add("displayProperty=NAME") + .add("outputType=EVENT") + .add("pageSize=10") + .add("page=1") + .add("dimension=INCIDENT_DATE:2021") + .add("desc=eventdate,lastupdated"); + + // When + ApiResponse response = actions.query().get("ur1Edk5Oe2n", 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, + 10, + 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,\"total\":27,\"pageSize\":10,\"pageCount\":3},\"items\":{\"ImspTQPwCqd\":{\"name\":\"Sierra Leone\"},\"EPEcjy3FWmI\":{\"name\":\"Lab monitoring\"},\"pe\":{},\"ur1Edk5Oe2n\":{\"name\":\"TB program\"},\"ou\":{},\"jdRD35YwbRH\":{\"name\":\"Sputum smear microscopy test\"},\"2021\":{\"name\":\"2021\"},\"ZkbAXlQUYJG\":{\"name\":\"TB visit\"},\"incidentdate\":{\"name\":\"Start of treatment date\"}},\"dimensions\":{\"pe\":[],\"ou\":[\"ImspTQPwCqd\"],\"incidentdate\":[\"2021\"]}}"; + 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, + "oucode", + "Organisation unit code", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "enrollmentdate", + "Start of treatment date", + "DATETIME", + "java.time.LocalDateTime", + 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, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 0, "enrollmentdate", "2021-11-11 12:27:48.386"); + + // Validate selected values for row index 3 + validateRowValueByName(response, actualHeaders, 3, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 3, "enrollmentdate", "2021-05-19 12:27:48.317"); + + // Validate selected values for row index 6 + validateRowValueByName(response, actualHeaders, 6, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 6, "enrollmentdate", "2021-09-11 12:27:48.552"); + + // Validate selected values for row index 9 + validateRowValueByName(response, actualHeaders, 9, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 9, "enrollmentdate", "2021-05-14 12:35:24.03"); + } + + @Test + public void enrollmentOuWithLevel() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("asc=eventdate") + .add("headers=oucode,enrollmentou,enrollmentouname") + .add("displayProperty=NAME") + .add("pageSize=10") + .add("page=1") + .add("dimension=ENROLLMENT_OU:LEVEL-m9lBJogzE95,pe:2021") + .add("desc=eventdate,lastupdated"); + + // When + ApiResponse response = actions.query().get("ur1Edk5Oe2n", 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, + 10, + 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,\"total\":10,\"pageSize\":10,\"pageCount\":1},\"items\":{\"EPEcjy3FWmI\":{\"name\":\"Lab monitoring\"},\"pe\":{},\"ur1Edk5Oe2n\":{\"name\":\"TB program\"},\"jdRD35YwbRH\":{\"name\":\"Sputum smear microscopy test\"},\"2021\":{\"name\":\"2021\"},\"ZkbAXlQUYJG\":{\"name\":\"TB visit\"}},\"dimensions\":{\"enrollmentou\":[],\"pe\":[]}}"; + 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, + "oucode", + "Organisation unit code", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "enrollmentou", + "Enrollment org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "enrollmentouname", + "Enrollment 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, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 0, "enrollmentouname", "Ngelehun CHC"); + + // Validate selected values for row index 3 + validateRowValueByName(response, actualHeaders, 3, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 3, "enrollmentouname", "Ngelehun CHC"); + + // Validate selected values for row index 6 + validateRowValueByName(response, actualHeaders, 6, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 6, "enrollmentouname", "Ngelehun CHC"); + + // Validate selected values for row index 9 + validateRowValueByName(response, actualHeaders, 9, "oucode", "OU_559"); + validateRowValueByName(response, actualHeaders, 9, "enrollmentouname", "Ngelehun CHC"); + } + + @Test + public void enrollmentOuWithMultipleOus() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("asc=eventdate") + .add("headers=oucode,enrollmentou,enrollmentouname") + .add("displayProperty=NAME") + .add("pageSize=10") + .add("page=1") + .add("dimension=ENROLLMENT_OU:BXd3TqaAxkK;VpYAl8dXs6m;uFp0ztDOFbI,pe:2021") + .add("desc=eventdate,lastupdated"); + + // When + ApiResponse response = actions.query().get("IpHINAT79UW", 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, + 10, + 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,\"total\":107,\"pageSize\":10,\"pageCount\":11},\"items\":{\"pe\":{},\"IpHINAT79UW\":{\"name\":\"Child Programme\"},\"ZzYYXq4fJie\":{\"name\":\"Baby Postnatal\"},\"uFp0ztDOFbI\":{\"name\":\"Bendu CHC\"},\"A03MvHHogjR\":{\"name\":\"Birth\"},\"BXd3TqaAxkK\":{\"name\":\"Sahun (Bumpeh) MCHP\"},\"2021\":{\"name\":\"2021\"},\"VpYAl8dXs6m\":{\"name\":\"Bendoma (Malegohun) MCHP\"}},\"dimensions\":{\"enrollmentou\":[\"BXd3TqaAxkK\",\"VpYAl8dXs6m\",\"uFp0ztDOFbI\"],\"pe\":[]}}"; + 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, + "oucode", + "Organisation unit code", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "enrollmentou", + "Enrollment org unit", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "enrollmentouname", + "Enrollment 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, "oucode", "OU_222676"); + validateRowValueByName( + response, actualHeaders, 0, "enrollmentouname", "Bendoma (Malegohun) MCHP"); + + // Validate selected values for row index 3 + validateRowValueByName(response, actualHeaders, 3, "oucode", "OU_222676"); + validateRowValueByName( + response, actualHeaders, 3, "enrollmentouname", "Bendoma (Malegohun) MCHP"); + + // Validate selected values for row index 6 + validateRowValueByName(response, actualHeaders, 6, "oucode", "OU_247031"); + validateRowValueByName(response, actualHeaders, 6, "enrollmentouname", "Sahun (Bumpeh) MCHP"); + + // Validate selected values for row index 9 + validateRowValueByName(response, actualHeaders, 9, "oucode", "OU_197430"); + validateRowValueByName(response, actualHeaders, 9, "enrollmentouname", "Bendu CHC"); + } } 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 3ad743a3e46e..94cdf6ae19d3 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 @@ -132,6 +132,27 @@ "version": { "min": 43 } + }, + { + "name": "enrollmentDate", + "query": "/api/analytics/events/aggregate/ur1Edk5Oe2n.json?dimension=ENROLLMENT_DATE:2021&displayProperty=NAME&totalPages=false", + "version": { + "min": 43 + } + }, + { + "name": "incidentDate", + "query": "/api/analytics/events/aggregate/ur1Edk5Oe2n.json?dimension=INCIDENT_DATE:2021&displayProperty=NAME&totalPages=false", + "version": { + "min": 43 + } + }, + { + "name": "enrollmentOuWithMultipleOus", + "query": "/api/analytics/events/aggregate/IpHINAT79UW.json?dimension=ENROLLMENT_OU:BXd3TqaAxkK;VpYAl8dXs6m;uFp0ztDOFbI,pe:2021&displayProperty=NAME&totalPages=false", + "version": { + "min": 43 + } } ] } 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 9a845af29147..9411d3913975 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 @@ -195,6 +195,42 @@ "version": { "min": 43 } + }, + { + "name": "enrollmentDate", + "query": "/api/analytics/events/query/ur1Edk5Oe2n.json?asc=eventdate&headers=oucode,enrollmentdate&displayProperty=NAME&outputType=EVENT&pageSize=10&page=1&dimension=ENROLLMENT_DATE:2021&desc=eventdate,lastupdated", + "version": { + "min": 43 + } + }, + { + "name": "enrollmentDateRelativePeriod", + "query": "/api/analytics/events/query/ur1Edk5Oe2n.json?relativePeriodDate=2021-11-11&asc=eventdate&headers=oucode,enrollmentdate&displayProperty=NAME&outputType=EVENT&pageSize=10&page=1&dimension=ENROLLMENT_DATE:LAST_6_MONTHS&desc=eventdate,lastupdated", + "version": { + "min": 43 + } + }, + { + "name": "enrollmentIncidentDateFixedYear", + "query": "/api/analytics/events/query/ur1Edk5Oe2n.json?asc=eventdate&headers=oucode,enrollmentdate&displayProperty=NAME&outputType=EVENT&pageSize=10&page=1&dimension=INCIDENT_DATE:2021&desc=eventdate,lastupdated", + "version": { + "min": 43 + } + }, + { + "name": "enrollmentOuWithLevel", + "query": "/api/analytics/events/query/ur1Edk5Oe2n.json?asc=eventdate&headers=oucode,enrollmentou,enrollmentouname&displayProperty=NAME&pageSize=10&page=1&dimension=ENROLLMENT_OU:LEVEL-m9lBJogzE95&dimension=pe:2021&desc=eventdate,lastupdated", + "version": { + "min": 43 + } + }, + { + "name": "enrollmentOuWithMultipleOus", + "query": "/api/analytics/events/query/IpHINAT79UW.json?asc=eventdate&headers=oucode,enrollmentou,enrollmentouname&displayProperty=NAME&pageSize=10&page=1&dimension=ENROLLMENT_OU:BXd3TqaAxkK;VpYAl8dXs6m;uFp0ztDOFbI,pe:2021&desc=eventdate,lastupdated", + "version": { + "min": 43 + } } + ] }