Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ public enum Type {
CREATED_BY("createdBy", DATA_X),
LAST_UPDATED_BY("lastUpdatedBy", DATA_X),
OU("ou", ORGANISATION_UNIT),
ENROLLMENT_OU("enrollmentOu", ORGANISATION_UNIT);
ENROLLMENT_OU("enrollmentOu", ORGANISATION_UNIT),
REGISTRATION_OU("registrationOu", ORGANISATION_UNIT);

private final String dimension;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,11 @@ public enum ErrorCode {
E7256("Value `{0}` is not a numeric tracked entity attribute of tracked entity type `{1}`"),
E7257(
"Value `{0}` does not reference a numeric data element of a program stage; expected format `programUid.programStageUid.dataElementUid`"),
E7258("Dimension `{0}` is not supported for a program without registration"),
E7259("Dimension `{0}` must specify organisation units in an aggregate query"),
E7260(
"Dimension `{0}` does not support organisation units at different hierarchy levels in an aggregate query"),
E7261("Sorting by `{0}` requires the `{1}` dimension"),

/* Org unit analytics */
E7300(Constants.AT_LEAST_ONE_ORGANISATION_UNIT_MUST_BE_SPECIFIED),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,21 @@ public record GeometrySource(String coordinateField, String source) {}
/** Whether ENROLLMENT_OU dimension was requested via relative keywords (e.g. USER_ORGUNIT). */
private boolean enrollmentOuDimensionHierarchical = false;

/**
* Org units when REGISTRATION_OU is used as a dimension, already expanded from any keyword form
* and therefore carrying their hierarchy level.
*/
private List<OrganisationUnit> registrationOuDimensionItems = new ArrayList<>();

/** Org units when REGISTRATION_OU is used as a filter. */
private List<OrganisationUnit> registrationOuFilterItems = new ArrayList<>();

/**
* Whether REGISTRATION_OU was named as a dimension, independently of whether it carries items. A
* dimension without items projects the output columns without restricting any rows.
*/
private boolean registrationOuDimensionRequested = false;

// -------------------------------------------------------------------------
// Constructors
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -370,6 +385,9 @@ protected EventQueryParams instance() {
params.enrollmentOuDimensionLevels = new LinkedHashSet<>(this.enrollmentOuDimensionLevels);
params.enrollmentOuFilterLevels = new LinkedHashSet<>(this.enrollmentOuFilterLevels);
params.enrollmentOuDimensionHierarchical = this.enrollmentOuDimensionHierarchical;
params.registrationOuDimensionItems = new ArrayList<>(this.registrationOuDimensionItems);
params.registrationOuFilterItems = new ArrayList<>(this.registrationOuFilterItems);
params.registrationOuDimensionRequested = this.registrationOuDimensionRequested;
return params;
}

Expand Down Expand Up @@ -1297,6 +1315,51 @@ public boolean hasEnrollmentOuDimension() {
return isNotEmpty(enrollmentOuDimensionItems) || !enrollmentOuDimensionLevels.isEmpty();
}

/** Returns true if REGISTRATION_OU was named as a dimension, with or without items. */
public boolean hasRegistrationOuDimension() {
return registrationOuDimensionRequested;
}

public boolean hasRegistrationOuFilter() {
return isNotEmpty(registrationOuFilterItems);
}

/** Returns true if REGISTRATION_OU was named at all, as a dimension or as a filter. */
public boolean hasRegistrationOu() {
return hasRegistrationOuDimension() || hasRegistrationOuFilter();
}

/**
* Returns true if the REGISTRATION_OU dimension carries org units, which is the condition for the
* aggregate disaggregation column to exist. A dimension named without org units projects the
* query output columns but has nothing to group by.
*/
public boolean hasRegistrationOuAggregateColumn() {
return isNotEmpty(registrationOuDimensionItems);
}

/**
* Returns true if REGISTRATION_OU restricts the query, i.e. carries org units as a dimension or
* as a filter. A dimension named without items does not restrict anything and so does not count
* as an organisation unit condition.
*/
public boolean hasRegistrationOuRestriction() {
return isNotEmpty(registrationOuDimensionItems) || isNotEmpty(registrationOuFilterItems);
}

public List<OrganisationUnit> getRegistrationOuDimensionItems() {
return registrationOuDimensionItems;
}

public List<OrganisationUnit> getRegistrationOuFilterItems() {
return registrationOuFilterItems;
}

/** Returns the REGISTRATION_OU org units from both the dimension and the filter. */
public List<OrganisationUnit> getAllRegistrationOuItems() {
return ListUtils.union(registrationOuDimensionItems, registrationOuFilterItems);
}

public boolean hasEnrollmentOuFilter() {
return isNotEmpty(enrollmentOuFilterItems) || !enrollmentOuFilterLevels.isEmpty();
}
Expand Down Expand Up @@ -1959,6 +2022,17 @@ public Builder withEnrollmentOuFilterLevels(Set<Integer> levels) {
return this;
}

public Builder withRegistrationOuDimension(List<OrganisationUnit> items) {
this.params.registrationOuDimensionItems = new ArrayList<>(items);
this.params.registrationOuDimensionRequested = true;
return this;
}

public Builder withRegistrationOuFilter(List<OrganisationUnit> items) {
this.params.registrationOuFilterItems = new ArrayList<>(items);
return this;
}

public Builder withEnrollmentOuDimensionHierarchical(boolean hierarchical) {
this.params.enrollmentOuDimensionHierarchical = hierarchical;
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagDataHandler;
import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagInfoInitializer;
import org.hisp.dhis.analytics.event.data.programindicator.disag.PiDisagQueryGenerator;
import org.hisp.dhis.analytics.event.data.registrationou.RegistrationOuSqlCoordinator;
import org.hisp.dhis.analytics.event.data.stage.StageQuerySqlFacade;
import org.hisp.dhis.analytics.table.EnrollmentAnalyticsColumnName;
import org.hisp.dhis.analytics.table.EventAnalyticsColumnName;
Expand Down Expand Up @@ -599,6 +600,10 @@ protected void addDimensionSelectColumns(
OrgUnitSqlCoordinator.addDimensionSelectColumns(
columns, params, isGroupByClause, isAggregated, getAnalyticsType(), sqlBuilder);

RegistrationOuSqlCoordinator.dimensionSelectColumn(
params, isGroupByClause, isAggregated, sqlBuilder)
.ifPresent(columns::add);

if (params.hasEnrollmentStatuses() && params.isEnrollmentAggregateQuery()) {
columns.add(ColumnAndAlias.ofColumn(ENROLLMENT_STATUS_COLUMN_NAME).asSql());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import java.util.Locale;
import java.util.function.BiFunction;
import java.util.function.Function;
import org.hisp.dhis.analytics.common.ColumnHeader;
import org.hisp.dhis.analytics.event.EventQueryParams;
import org.hisp.dhis.analytics.event.data.ou.OrgUnitRowAccess;
import org.hisp.dhis.common.DimensionalObject;
Expand Down Expand Up @@ -206,6 +207,11 @@ private void addDimensionData() {
row.add(extractStringValue(OrgUnitRowAccess.enrollmentOuResultColumn(), ValueType.TEXT));
}

// A REGISTRATION_OU dimension without items emits no aggregate column, so nothing to read.
if (params.hasRegistrationOuAggregateColumn()) {
row.add(extractStringValue(ColumnHeader.REGISTRATION_OU.getItem(), ValueType.TEXT));
}

if (params.hasEnrollmentStatuses() && params.isAggregatedEvents()) {
row.add(rowSet.getString(ENROLLMENT_STATUS_COLUMN_NAME));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
import org.hisp.dhis.analytics.event.QueryItemLocator;
import org.hisp.dhis.analytics.event.data.ou.OrgUnitSqlConstants;
import org.hisp.dhis.analytics.event.data.queryitem.QueryItemFilterHandlerRegistry;
import org.hisp.dhis.analytics.event.data.registrationou.RegistrationOuSqlConstants;
import org.hisp.dhis.analytics.table.EnrollmentAnalyticsColumnName;
import org.hisp.dhis.analytics.table.EventAnalyticsColumnName;
import org.hisp.dhis.common.BaseDimensionalItemObject;
Expand Down Expand Up @@ -123,6 +124,7 @@ public class DefaultEventDataQueryService implements EventDataQueryService {
private static final String SCHEDULED_DATE_DIMENSION = "SCHEDULED_DATE";

private static final String ENROLLMENT_OU_DIMENSION = "ENROLLMENT_OU";
private static final String REGISTRATION_OU_DIMENSION = RegistrationOuSqlConstants.DIMENSION_NAME;
private static final String LEVEL_PREFIX = "LEVEL-";

private final ProgramService programService;
Expand Down Expand Up @@ -718,6 +720,12 @@ private void addFiltersToParams(
resolveEnrollmentOuFilter(params, request, userOrgUnits, input.items(), idScheme);
continue;
}
if (REGISTRATION_OU_DIMENSION.equals(input.dimensionId())) {
requireRegistrationProgram(pr);
params.withRegistrationOuFilter(
resolveRegistrationOuItems(input.items(), request, userOrgUnits, idScheme));
continue;
}
if (isProgramStatusDimension(input.dimensionId())) {
if (isAggregateRequest(request)) {
requireNonEmptyStatusFilter(input.items(), input.rawDimension());
Expand Down Expand Up @@ -768,6 +776,11 @@ private void addDimensionsToParams(
resolveEnrollmentOuDimension(params, request, userOrgUnits, input.items(), idScheme);
continue;
}
if (REGISTRATION_OU_DIMENSION.equals(input.dimensionId())) {
resolveRegistrationOuDimension(
params, request, userOrgUnits, input.items(), idScheme, pr);
continue;
}
if (isProgramStatusDimension(input.dimensionId())) {
if (isAggregateRequest(request)) {
params.addDimension(getProgramStatusDimension(input.items(), input.rawDimension()));
Expand Down Expand Up @@ -863,7 +876,8 @@ private void processDimension(
String dimensionId = getDimensionFromParam(rawDimension);
List<String> items = getDimensionItemsFromParam(rawDimension);

if (ENROLLMENT_OU_DIMENSION.equals(dimensionId)) {
if (ENROLLMENT_OU_DIMENSION.equals(dimensionId)
|| REGISTRATION_OU_DIMENSION.equals(dimensionId)) {
normalizedInputs.add(
new NormalizedDimensionInput(rawDimension, dimensionId, items, groupUUID));
return;
Expand Down Expand Up @@ -903,9 +917,34 @@ private List<NormalizedDimensionInput> normalizeDimensionInputs(
}

peTracker.insertMergedInto(normalizedInputs);
rejectRepeatedOrgUnitDimensions(normalizedInputs);
return normalizedInputs;
}

/**
* Rejects a request naming ENROLLMENT_OU or REGISTRATION_OU more than once. Both are held outside
* {@code params.dimensions} and so are invisible to {@link
* org.hisp.dhis.analytics.DataQueryParams#getDuplicateDimensions()}, which is what raises E7201
* for an ordinary dimension. Without this check the last occurrence processed would silently win,
* and which one that is follows hash order rather than the order the caller wrote them in.
*
* <p>Several org units in one dimension ({@code REGISTRATION_OU:uidA;uidB}) is a single
* occurrence and stays valid, as does the same dimension used once as a dimension and once as a
* filter, since those are normalized separately.
*/
private void rejectRepeatedOrgUnitDimensions(List<NormalizedDimensionInput> normalizedInputs) {
for (String dimensionId : List.of(ENROLLMENT_OU_DIMENSION, REGISTRATION_OU_DIMENSION)) {
long occurrences =
normalizedInputs.stream()
.filter(input -> dimensionId.equals(input.dimensionId()))
.count();

if (occurrences > 1) {
throwIllegalQueryEx(ErrorCode.E7201, dimensionId);
}
}
}

private record NormalizedDimensionInput(
String rawDimension, String dimensionId, List<String> items, UUID groupUUID) {}

Expand Down Expand Up @@ -1207,6 +1246,57 @@ private EnrollmentOuResolution resolveEnrollmentOuItems(
return new EnrollmentOuResolution(uidItems, levels);
}

/**
* Resolves REGISTRATION_OU items through the standard "ou" dimension pipeline, so that every
* keyword form (USER_ORGUNIT and its variants, LEVEL-n, OU_GROUP-uid) expands to concrete org
* units already carrying their hierarchy level.
*/
private List<OrganisationUnit> resolveRegistrationOuItems(
List<String> items,
EventDataQueryRequest request,
List<OrganisationUnit> userOrgUnits,
IdScheme idScheme) {
if (items == null || items.isEmpty()) {
return List.of();
}

DimensionalObject ouDimension =
dataQueryService.getDimension("ou", items, request, userOrgUnits, true, idScheme);

if (ouDimension == null) {
return List.of();
}

return ouDimension.getItems().stream()
.filter(OrganisationUnit.class::isInstance)
.map(OrganisationUnit.class::cast)
.toList();
}

private void resolveRegistrationOuDimension(
EventQueryParams.Builder params,
EventDataQueryRequest request,
List<OrganisationUnit> userOrgUnits,
List<String> items,
IdScheme idScheme,
Program program) {
requireRegistrationProgram(program);

if ((items == null || items.isEmpty()) && isAggregateRequest(request)) {
throwIllegalQueryEx(ErrorCode.E7259, REGISTRATION_OU_DIMENSION);
}

params.withRegistrationOuDimension(
resolveRegistrationOuItems(items, request, userOrgUnits, idScheme));
}

/** Registration org unit is a property of a tracked entity, so it needs a tracker program. */
private void requireRegistrationProgram(Program program) {
if (program != null && !program.isRegistration()) {
throwIllegalQueryEx(ErrorCode.E7258, REGISTRATION_OU_DIMENSION);
}
}

private record EnrollmentOuResolution(
List<DimensionalItemObject> uidItems, Set<Integer> levels) {}

Expand All @@ -1229,6 +1319,10 @@ enum SortableItems {
ColumnHeader.SCHEDULED_DATE.getItem(), EventAnalyticsColumnName.SCHEDULED_DATE_COLUMN_NAME),
ORG_UNIT_NAME(ColumnHeader.ORG_UNIT_NAME.getItem()),
ORG_UNIT_NAME_HIERARCHY(ColumnHeader.ORG_UNIT_NAME_HIERARCHY.getItem()),
// Projected under their own aliases only when REGISTRATION_OU is a dimension, so the query
// validator rejects sorting on them otherwise.
REGISTRATION_OU(ColumnHeader.REGISTRATION_OU.getItem()),
REGISTRATION_OU_NAME(ColumnHeader.REGISTRATION_OU_NAME.getItem()),
ORG_UNIT_CODE(ColumnHeader.ORG_UNIT_CODE.getItem()),
PROGRAM_STATUS(
ColumnHeader.PROGRAM_STATUS.getItem(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.Strings;
import org.hisp.dhis.analytics.common.ColumnHeader;
import org.hisp.dhis.analytics.event.EventQueryParams;
import org.hisp.dhis.analytics.event.EventQueryValidator;
import org.hisp.dhis.analytics.event.data.registrationou.RegistrationOuSqlConstants;
import org.hisp.dhis.analytics.table.EnrollmentAnalyticsColumnName;
import org.hisp.dhis.analytics.table.EventAnalyticsColumnName;
import org.hisp.dhis.common.DimensionType;
Expand All @@ -56,6 +58,7 @@
import org.hisp.dhis.common.QueryOperator;
import org.hisp.dhis.common.RequestTypeAware;
import org.hisp.dhis.common.ValueType;
import org.hisp.dhis.commons.collection.ListUtils;
import org.hisp.dhis.feedback.ErrorCode;
import org.hisp.dhis.feedback.ErrorMessage;
import org.hisp.dhis.program.Program;
Expand All @@ -69,6 +72,11 @@
@RequiredArgsConstructor
public class DefaultEventQueryValidator implements EventQueryValidator {

private static final String REGISTRATION_OU_DIMENSION = RegistrationOuSqlConstants.DIMENSION_NAME;

private static final Set<String> REGISTRATION_OU_SORT_ITEMS =
Set.of(ColumnHeader.REGISTRATION_OU.getItem(), ColumnHeader.REGISTRATION_OU_NAME.getItem());

private final SystemSettingsProvider settingsProvider;

// -------------------------------------------------------------------------
Expand All @@ -95,7 +103,9 @@ public ErrorMessage validateForErrorMessage(EventQueryParams params) {
if (params == null) {
throw new IllegalQueryException(ErrorCode.E7100);
}
if (!params.hasOrganisationUnits() && !params.hasEnrollmentOu()) {
if (!params.hasOrganisationUnits()
&& !params.hasEnrollmentOu()
&& !params.hasRegistrationOuRestriction()) {
return new ErrorMessage(ErrorCode.E7200);
}
Optional<String> enrollmentOuSortColumn = params.getEnrollmentOuSortColumn();
Expand All @@ -105,6 +115,10 @@ public ErrorMessage validateForErrorMessage(EventQueryParams params) {
if (!params.getDuplicateDimensions().isEmpty()) {
return new ErrorMessage(ErrorCode.E7201, params.getDuplicateDimensions());
}
ErrorMessage registrationOuSortError = validateRegistrationOuSorting(params);
if (registrationOuSortError != null) {
return registrationOuSortError;
}

// Check for duplicate stage dimension identifiers (must be before E7202 check)
// This applies to both EVENT and ENROLLMENT queries
Expand Down Expand Up @@ -209,6 +223,27 @@ public ErrorMessage validateForErrorMessage(EventQueryParams params) {
return null;
}

/**
* Rejects sorting by a registration organisation unit column when REGISTRATION_OU is not a
* dimension. Both columns are projected only for the dimension, so {@code registrationouname}
* would order by an alias that is not in the select list, and {@code registrationou} would fall
* back to the raw analytics column and order by the organisation unit the tracked entity was
* registered in rather than the requested ancestor. A dimension named without organisation units
* still projects both columns, so it is enough.
*/
private ErrorMessage validateRegistrationOuSorting(EventQueryParams params) {
if (!params.isSorting() || params.hasRegistrationOuDimension()) {
return null;
}

return ListUtils.union(params.getAsc(), params.getDesc()).stream()
.map(QueryItem::getItemId)
.filter(REGISTRATION_OU_SORT_ITEMS::contains)
.findFirst()
.map(item -> new ErrorMessage(ErrorCode.E7261, item, REGISTRATION_OU_DIMENSION))
.orElse(null);
}

private boolean hasDateQueryItem(EventQueryParams params) {
return params.getItemsAndItemFilters().stream()
.anyMatch(
Expand Down
Loading
Loading