From 613859bbea749f5348228a62a6c95436effda3ff Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 23 Jan 2025 15:57:10 +0100 Subject: [PATCH 01/20] feat: Filter events by existing data element [DHIS2-15954] From 06269e33eb0eb1ec68de754da9752f7a991c630b Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 23 Jan 2025 16:02:12 +0100 Subject: [PATCH 02/20] feat: Filter events by existing data element [DHIS2-15954] --- .../tracker/export/event/JdbcEventStore.java | 5 +++++ .../export/event/EventExporterTest.java | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java index 465fa3497125..7fe0fed94255 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java @@ -1444,6 +1444,11 @@ private StringBuilder dataElementAndFiltersSql( } } } + } else { + eventDataValuesWhereSql.append(hlp.whereAnd()); + eventDataValuesWhereSql.append(" (ev.eventdatavalues ?? '"); + eventDataValuesWhereSql.append(item.getKey().getUid()); + eventDataValuesWhereSql.append("')"); } } diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java index 83b4f9226bca..fad784b2c6b0 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java @@ -42,6 +42,7 @@ import java.io.IOException; import java.time.ZoneId; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; @@ -590,6 +591,25 @@ void testExportEventsWhenFilteringByNumericDataElements() assertContainsOnly(List.of("D9PbzJY8bJM"), events); } + @Test + void shouldFilterByEventsWithGivenDataValuesWhenFilterContainsDataElementUIDsOnly() + throws ForbiddenException, BadRequestException { + EventOperationParams params = + EventOperationParams.builder() + .eventParams(EventParams.FALSE) + .dataElementFilters( + Map.of( + UID.of("GieVkTxp4HH"), + new ArrayList<>(), + UID.of("GieVkTxp4HG"), + new ArrayList<>())) + .build(); + + List events = getEvents(params); + + assertContainsOnly(List.of("kWjSezkXHVp"), events); + } + @Test void testEnrollmentEnrolledBeforeSetToBeforeFirstEnrolledAtDate() throws ForbiddenException, BadRequestException { From 1ddbce80d118010bb9232ffb7f7de48cfcb6342d Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 24 Jan 2025 10:17:41 +0100 Subject: [PATCH 03/20] feat: Avoid using dataElements map when sorting DEs [DHIS2-15954] --- .../org/hisp/dhis/tracker/export/event/EventQueryParams.java | 1 - 1 file changed, 1 deletion(-) diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/EventQueryParams.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/EventQueryParams.java index 7102c9eb5941..e593757a00d7 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/EventQueryParams.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/EventQueryParams.java @@ -414,7 +414,6 @@ public EventQueryParams orderBy(String field, SortDirection direction) { /** Order by the given data element {@code de} in given sort {@code direction}. */ public EventQueryParams orderBy(DataElement de, SortDirection direction) { this.order.add(new Order(de, direction)); - this.dataElements.putIfAbsent(de, new ArrayList<>()); return this; } From f19e3be2624d7724fc39762963d22cfab96c5a24 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 24 Jan 2025 10:19:34 +0100 Subject: [PATCH 04/20] feat: Add order fields in select clause from params order [DHIS2-15954] --- .../tracker/export/event/JdbcEventStore.java | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java index 7fe0fed94255..74ae62deb43d 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java @@ -893,16 +893,8 @@ private String getEventSelectQuery( .append(COLUMN_EVENT_ATTRIBUTE_OPTION_COMBO_NAME) .append(", coc_agg.attributevalues as ") .append(COLUMN_EVENT_ATTRIBUTE_OPTION_COMBO_ATTRIBUTE_VALUES) - .append(", coc_agg.co_values AS co_values, coc_agg.co_count AS option_size, "); - - for (Order order : params.getOrder()) { - if (order.getField() instanceof TrackedEntityAttribute tea) - selectBuilder - .append(quote(tea.getUid())) - .append(".value AS ") - .append(tea.getUid()) - .append("_value, "); - } + .append(", coc_agg.co_values AS co_values, coc_agg.co_count AS option_size, ") + .append(addOrderFieldsToSelectClause(params)); return selectBuilder .append( @@ -928,6 +920,32 @@ private String getEventSelectQuery( .toString(); } + private String addOrderFieldsToSelectClause(EventQueryParams params) { + StringBuilder selectBuilder = new StringBuilder(); + + for (Order order : params.getOrder()) { + if (order.getField() instanceof TrackedEntityAttribute tea) { + selectBuilder + .append(quote(tea.getUid())) + .append(".value AS ") + .append(tea.getUid()) + .append("_value, "); + } else if (order.getField() instanceof DataElement de) { + final String dataValueValueSql = "ev.eventdatavalues #>> '{" + de.getUid() + ", value}'"; + selectBuilder + .append( + de.getValueType().isNumeric() + ? castToNumber(dataValueValueSql) + : lower(dataValueValueSql)) + .append(" as ") + .append(de.getUid()) + .append(", "); + } + } + + return selectBuilder.toString(); + } + private boolean checkForOwnership(EventQueryParams params) { return Optional.ofNullable(params.getProgram()) .filter( @@ -1447,7 +1465,7 @@ private StringBuilder dataElementAndFiltersSql( } else { eventDataValuesWhereSql.append(hlp.whereAnd()); eventDataValuesWhereSql.append(" (ev.eventdatavalues ?? '"); - eventDataValuesWhereSql.append(item.getKey().getUid()); + eventDataValuesWhereSql.append(deUid); eventDataValuesWhereSql.append("')"); } } From 2315c98d7aea3291a9a816e886a3a18a9a9e5f2b Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 24 Jan 2025 10:22:53 +0100 Subject: [PATCH 05/20] feat: Replace params for order list in method signature [DHIS2-15954] --- .../org/hisp/dhis/tracker/export/event/JdbcEventStore.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java index 74ae62deb43d..64dd9e941040 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java @@ -894,7 +894,7 @@ private String getEventSelectQuery( .append(", coc_agg.attributevalues as ") .append(COLUMN_EVENT_ATTRIBUTE_OPTION_COMBO_ATTRIBUTE_VALUES) .append(", coc_agg.co_values AS co_values, coc_agg.co_count AS option_size, ") - .append(addOrderFieldsToSelectClause(params)); + .append(addOrderFieldsToSelectClause(params.getOrder())); return selectBuilder .append( @@ -920,10 +920,10 @@ private String getEventSelectQuery( .toString(); } - private String addOrderFieldsToSelectClause(EventQueryParams params) { + private String addOrderFieldsToSelectClause(List orders) { StringBuilder selectBuilder = new StringBuilder(); - for (Order order : params.getOrder()) { + for (Order order : orders) { if (order.getField() instanceof TrackedEntityAttribute tea) { selectBuilder .append(quote(tea.getUid())) From edb875016ebf18e2bbfa6ceda75ae6ca4fff7b8c Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 24 Jan 2025 11:11:44 +0100 Subject: [PATCH 06/20] feat: Fix data element tests [DHIS2-15954] --- .../export/event/DefaultEventService.java | 7 ++++++- .../export/event/EventQueryParamsTest.java | 4 ++-- .../event/EventsExportControllerTest.java | 20 ++++++++++++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java index c20dad793c3d..1be03e3a7f7a 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java @@ -128,7 +128,12 @@ private FileResource getFileResourceMetadata(UID eventUid, UID dataElementUid) "this must be a bug in how the EventOperationParams are built"); } if (events.getItems().isEmpty()) { - throw new NotFoundException(Event.class, eventUid); + throw new NotFoundException( + "Event " + + eventUid.getValue() + + " with data element " + + dataElementUid.getValue() + + " could not be found."); } Event event = events.getItems().get(0); diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/event/EventQueryParamsTest.java b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/event/EventQueryParamsTest.java index 786c812455d7..3b26fc050fb6 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/event/EventQueryParamsTest.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/event/EventQueryParamsTest.java @@ -70,13 +70,13 @@ void shouldKeepExistingAttributeFiltersWhenOrderingByAttribute() { } @Test - void shouldAddDataElementToOrderAndDataElementsWhenOrderingByDataElement() { + void shouldAddDataElementToOrderButNotToDataElementsWhenOrderingByDataElement() { EventQueryParams params = new EventQueryParams(); params.orderBy(de1, SortDirection.ASC); assertEquals(List.of(new Order(de1, SortDirection.ASC)), params.getOrder()); - assertEquals(Map.of(de1, List.of()), params.getDataElements()); + assertTrue(params.getDataElements().isEmpty()); assertFalse(params.hasDataElementFilter()); } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventsExportControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventsExportControllerTest.java index 0daf050f0cfe..72b264a0d3c9 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventsExportControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventsExportControllerTest.java @@ -534,7 +534,6 @@ void getDataValuesFileByDataElementIfUserDoesNotHaveReadAccessToDataElement() event.getEventDataValues().add(dataValue(de, file.getUid())); manager.update(event); - // this.switchContextToUser(user); switchContextToUser(user); GET("/tracker/events/{eventUid}/dataValues/{dataElementUid}/file", event.getUid(), de.getUid()) @@ -568,6 +567,25 @@ void getDataValuesFileByDataElementIfNoDataValueExists() { switchContextToUser(user); + assertEquals( + "Event " + event.getUid() + " with data element " + de.getUid() + " could not be found.", + GET( + "/tracker/events/{eventUid}/dataValues/{dataElementUid}/file", + event.getUid(), + de.getUid()) + .error(HttpStatus.NOT_FOUND) + .getMessage()); + } + + @Test + void getDataValuesFileByDataElementIfNoDataValueNull() { + Event event = event(enrollment(trackedEntity())); + DataElement de = dataElement(ValueType.IMAGE); + + event.getEventDataValues().add(dataValue(de, null)); + manager.flush(); + switchContextToUser(user); + assertEquals( "DataValue for data element " + de.getUid() + " could not be found.", GET( From da9c871fb50539c23598e031df5de526bb2f755e Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 27 Jan 2025 14:28:51 +0100 Subject: [PATCH 07/20] feat: Filter events by DE existence in event [DHIS2-15954] --- .../org/hisp/dhis/common/QueryFilter.java | 6 + .../org/hisp/dhis/common/QueryOperator.java | 1 + .../tracker/export/event/JdbcEventStore.java | 31 ++++- .../export/event/EventExporterTest.java | 24 +++- .../export/RequestParamsValidator.java | 84 +++++++++++-- .../event/EventRequestParamsMapper.java | 8 +- .../TrackedEntityRequestParamsMapper.java | 5 +- .../export/RequestParamsValidatorTest.java | 114 +++++++++++++++--- 8 files changed, 232 insertions(+), 41 deletions(-) diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java index b74fbc3c264f..de7f26ebb453 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java @@ -30,6 +30,7 @@ import static org.hisp.dhis.analytics.QueryKey.NV; import static org.hisp.dhis.common.QueryOperator.EQ; import static org.hisp.dhis.common.QueryOperator.EW; +import static org.hisp.dhis.common.QueryOperator.EX; import static org.hisp.dhis.common.QueryOperator.GE; import static org.hisp.dhis.common.QueryOperator.GT; import static org.hisp.dhis.common.QueryOperator.IEQ; @@ -80,6 +81,7 @@ public class QueryFilter { .put(EW, unused -> "like") .put(NLIKE, unused -> "not like") .put(IN, unused -> "in") + .put(EX, unused -> "??") .build(); protected QueryOperator operator; @@ -92,6 +94,10 @@ public class QueryFilter { public QueryFilter() {} + public QueryFilter(QueryOperator operator) { + this.operator = operator; + } + public QueryFilter(QueryOperator operator, String filter) { this.operator = operator; this.filter = filter; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java index cb3cffef7b48..b06ef30ea92c 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java @@ -51,6 +51,7 @@ public enum QueryOperator { IN("in", true), SW("sw"), EW("ew"), + EX("??"), // Analytics specifics IEQ("==", true), NE("!=", true), diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java index 64dd9e941040..ba55b3cf000e 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java @@ -1375,6 +1375,12 @@ private StringBuilder dataElementAndFiltersSql( .append(" as ") .append(deUid); + if (filterContainsExistenceOperator(filters)) { + // Operator EX allows for only one item in the filter list and its value is true or false + eventDataValuesWhereSql.append(addExistsFilterCondition(filters.get(0), hlp, deUid)); + break; + } + String optValueTableAs = "opt_" + filterCount; if (!joinedColumns.contains(deUid) && de.hasOptionSet() && !filters.isEmpty()) { @@ -1462,11 +1468,6 @@ private StringBuilder dataElementAndFiltersSql( } } } - } else { - eventDataValuesWhereSql.append(hlp.whereAnd()); - eventDataValuesWhereSql.append(" (ev.eventdatavalues ?? '"); - eventDataValuesWhereSql.append(deUid); - eventDataValuesWhereSql.append("')"); } } @@ -1476,6 +1477,22 @@ private StringBuilder dataElementAndFiltersSql( .append(" "); } + private String addExistsFilterCondition(QueryFilter queryFilter, SqlHelper hlp, String deUid) { + StringBuilder existsBuilder = new StringBuilder(); + + existsBuilder.append(hlp.whereAnd()); + if (!Boolean.parseBoolean(queryFilter.getFilter())) { + existsBuilder.append(" not "); + } + existsBuilder.append(" (ev.eventdatavalues "); + existsBuilder.append(queryFilter.getSqlOperator()); + existsBuilder.append(" '"); + existsBuilder.append(deUid); + existsBuilder.append("')"); + + return existsBuilder.toString(); + } + private String inCondition(QueryFilter filter, String boundParameter, String queryCol) { return new StringBuilder() .append(" ") @@ -1490,6 +1507,10 @@ private String inCondition(QueryFilter filter, String boundParameter, String que .toString(); } + private boolean filterContainsExistenceOperator(List filters) { + return filters.stream().anyMatch(qf -> qf.getOperator().equals(QueryOperator.EX)); + } + private String eventStatusSql( EventQueryParams params, MapSqlParameterSource mapSqlParameterSource, SqlHelper hlp) { StringBuilder stringBuilder = new StringBuilder(); diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java index fad784b2c6b0..264c913818a0 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java @@ -42,7 +42,6 @@ import java.io.IOException; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; @@ -592,7 +591,7 @@ void testExportEventsWhenFilteringByNumericDataElements() } @Test - void shouldFilterByEventsWithGivenDataValuesWhenFilterContainsDataElementUIDsOnly() + void shouldFilterByEventsContainingGivenDataValuesWhenFilteringByExistence() throws ForbiddenException, BadRequestException { EventOperationParams params = EventOperationParams.builder() @@ -600,9 +599,9 @@ void shouldFilterByEventsWithGivenDataValuesWhenFilterContainsDataElementUIDsOnl .dataElementFilters( Map.of( UID.of("GieVkTxp4HH"), - new ArrayList<>(), + List.of(new QueryFilter(QueryOperator.EX, "true")), UID.of("GieVkTxp4HG"), - new ArrayList<>())) + List.of(new QueryFilter(QueryOperator.EX, "true")))) .build(); List events = getEvents(params); @@ -610,6 +609,23 @@ void shouldFilterByEventsWithGivenDataValuesWhenFilterContainsDataElementUIDsOnl assertContainsOnly(List.of("kWjSezkXHVp"), events); } + @Test + void shouldFilterByEventsNotContainingGivenDataValueWhenFilteringByNonexistence() + throws ForbiddenException, BadRequestException { + EventOperationParams params = + EventOperationParams.builder() + .enrollments(UID.of("nxP7UnKhomJ", "TvctPPhpD8z")) + .programStage(programStage) + .eventParams(EventParams.FALSE) + .dataElementFilters( + Map.of(UID.of("DATAEL00002"), List.of(new QueryFilter(QueryOperator.EX, "false")))) + .build(); + + List events = getEvents(params); + + assertContainsOnly(List.of("pTzf9KYMk72"), events); + } + @Test void testEnrollmentEnrolledBeforeSetToBeforeFirstEnrolledAtDate() throws ForbiddenException, BadRequestException { diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java index c85d3fe6bcf9..460cbb4b037e 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java @@ -277,7 +277,7 @@ public static void validateOrderParams(List order, Set su /** * Validate the {@code filter} request parameter in change log tracker exporters. Allowed filter * values are {@code supportedFieldNames}. Only one field name at a time can be specified. If the - * endpoint supports UIDs use {@link #parseFilters(String)}. + * endpoint supports UIDs use {@link #parseAttributeFilters(String)}. */ public static void validateFilter(String filter, Set>> supportedFields) throws BadRequestException { @@ -324,12 +324,13 @@ public static void validateFilter(String filter, Set>> sup /** * Parse given {@code input} string representing a filter for an object referenced by a UID like a - * tracked entity attribute or data element. Refer to {@link #parseSanitizedFilters(Map, String)}} - * for details on the expected input format. + * tracked entity attribute. Refer to {@link #parseSanitizedFilters(Map, String)}} for details on + * the expected input format. * * @return filters by UIDs */ - public static Map> parseFilters(String input) throws BadRequestException { + public static Map> parseAttributeFilters(String input) + throws BadRequestException { Map> result = new HashMap<>(); if (StringUtils.isBlank(input)) { return result; @@ -342,9 +343,30 @@ public static Map> parseFilters(String input) throws BadR } /** - * Accumulate {@link QueryFilter}s per UID by parsing given input string of format - * {uid}[:{operator}:{value}]. Only the UID is mandatory. Multiple operator:value pairs are - * allowed. A {@link QueryFilter} for each operator:value pair is added to the corresponding UID. + * Parse given {@code input} string representing a filter for an object referenced by a UID like a + * data element. Refer to {@link #parseSanitizedDataElementFilters(Map, String)}} for details on + * the expected input format. + * + * @return filters by UIDs + */ + public static Map> parseDataElementFilters(String input) + throws BadRequestException { + Map> result = new HashMap<>(); + if (StringUtils.isBlank(input)) { + return result; + } + + for (String uidOperatorValue : filterList(input)) { + parseSanitizedDataElementFilters(result, uidOperatorValue); + } + return result; + } + + /** + * Accumulate {@link QueryFilter}s per TEA UID by parsing given input string of format + * {uid}[:{operator}:{value}]. Only the TEA UID is mandatory. Multiple operator:value pairs are + * allowed. A {@link QueryFilter} for each operator:value pair is added to the corresponding TEA + * UID. * * @throws BadRequestException filter is neither multiple nor single operator:value format */ @@ -362,7 +384,55 @@ private static void parseSanitizedFilters(Map> result, St result.putIfAbsent(uid, new ArrayList<>()); String[] filters = FILTER_ITEM_SPLIT.split(input.substring(uidIndex)); + validateFilterLength(filters, result, uid, input); + } + /** + * Accumulate {@link QueryFilter}s per DE UID by parsing given input string of format + * {uid}[:{operator}:{value}]. Only the DE ID is mandatory. Multiple operator:value pairs are + * allowed. A {@link QueryFilter} for each operator:value pair is added to the corresponding DE + * UID. + * + * @throws BadRequestException filter is neither multiple nor single operator:value format + */ + private static void parseSanitizedDataElementFilters( + Map> result, String input) throws BadRequestException { + int uidIndex = input.indexOf(DIMENSION_NAME_SEP) + 1; + + if (uidIndex == 0 || input.length() == uidIndex) { + UID uid = UID.of(input.replace(DIMENSION_NAME_SEP, "")); + result.putIfAbsent(uid, List.of(new QueryFilter(QueryOperator.EX, "true"))); + return; + } + UID uid = UID.of(input.substring(0, uidIndex - 1)); + String[] filters = FILTER_ITEM_SPLIT.split(input.substring(uidIndex)); + validateExistenceOperator(filters, result, input, uid); + result.putIfAbsent(uid, new ArrayList<>()); + validateFilterLength(filters, result, uid, input); + } + + private static void validateExistenceOperator( + String[] filters, Map> result, String input, UID uid) + throws BadRequestException { + for (int i = 0; i < filters.length; i += 2) { + if (filters[i].equalsIgnoreCase(QueryOperator.EX.name())) { + if (!filters[1].equalsIgnoreCase("true") && !filters[1].equalsIgnoreCase("false")) { + throw new BadRequestException( + "A filter with the operator 'EX' can only have 'true' or 'false' as its value: " + + input); + } + if (result.containsKey(uid)) { + throw new BadRequestException( + "A filter with the operator 'EX' can only filter by a single value at a time: " + + input); + } + } + } + } + + private static void validateFilterLength( + String[] filters, Map> result, UID uid, String input) + throws BadRequestException { // single operator if (filters.length == 2) { result.get(uid).add(operatorValueQueryFilter(filters[0], filters[1], input)); diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapper.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapper.java index c50bfa86201d..becf82e66ccf 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapper.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapper.java @@ -29,7 +29,8 @@ import static java.util.Collections.emptySet; import static org.hisp.dhis.util.ObjectUtils.applyIfNotNull; -import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.parseFilters; +import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.parseAttributeFilters; +import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.parseDataElementFilters; import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.validateDeprecatedParameter; import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.validateDeprecatedUidsParameter; import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.validateOrderParams; @@ -109,9 +110,10 @@ public EventOperationParams map( "event", eventRequestParams.getEvent(), "events", eventRequestParams.getEvents()); validateFilter(eventRequestParams.getFilter(), eventUids); - Map> dataElementFilters = parseFilters(eventRequestParams.getFilter()); + Map> dataElementFilters = + parseDataElementFilters(eventRequestParams.getFilter()); Map> attributeFilters = - parseFilters(eventRequestParams.getFilterAttributes()); + parseAttributeFilters(eventRequestParams.getFilterAttributes()); Set assignedUsers = validateDeprecatedUidsParameter( diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntityRequestParamsMapper.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntityRequestParamsMapper.java index 74e31e410b7c..c29574fc28ca 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntityRequestParamsMapper.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntityRequestParamsMapper.java @@ -28,7 +28,7 @@ package org.hisp.dhis.webapi.controller.tracker.export.trackedentity; import static org.hisp.dhis.util.ObjectUtils.applyIfNotNull; -import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.parseFilters; +import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.parseAttributeFilters; import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.validateDeprecatedParameter; import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.validateDeprecatedUidsParameter; import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.validateOrderParams; @@ -123,7 +123,8 @@ public TrackedEntityOperationParams map( validateOrderParams(trackedEntityRequestParams.getOrder(), ORDERABLE_FIELD_NAMES, "attribute"); validateRequestParams(trackedEntityRequestParams, trackedEntities); - Map> filters = parseFilters(trackedEntityRequestParams.getFilter()); + Map> filters = + parseAttributeFilters(trackedEntityRequestParams.getFilter()); TrackedEntityOperationParamsBuilder builder = TrackedEntityOperationParams.builder() diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java index 01c7fa7eac55..72d7f425d419 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java @@ -31,7 +31,8 @@ import static org.hisp.dhis.test.utils.Assertions.assertContains; import static org.hisp.dhis.test.utils.Assertions.assertStartsWith; import static org.hisp.dhis.webapi.controller.event.webrequest.OrderCriteria.fromOrderString; -import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.parseFilters; +import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.parseAttributeFilters; +import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.parseDataElementFilters; import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.validateFilter; import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.validateOrderParams; import static org.hisp.dhis.webapi.controller.tracker.export.RequestParamsValidator.validateOrgUnitModeForEnrollmentsAndEvents; @@ -74,6 +75,10 @@ class RequestParamsValidatorTest { public static final UID TEA_3_UID = UID.of("cy2oRh2sNr7"); + private static final UID DE_1_UID = UID.of("D1jwTPToKHO"); + + private static final UID DE_2_UID = UID.of("D2jwTPToKHO"); + private static final OrganisationUnit orgUnit = new OrganisationUnit(); @Test @@ -208,9 +213,9 @@ void shouldFailWhenChangeLogFilterDoesNotHaveCorrectUidFormat() { } @Test - void shouldParseFilters() throws BadRequestException { + void shouldParseAttributeFilters() throws BadRequestException { Map> filters = - parseFilters(TEA_1_UID + ":lt:20:gt:10," + TEA_2_UID + ":like:foo"); + parseAttributeFilters(TEA_1_UID + ":lt:20:gt:10," + TEA_2_UID + ":like:foo"); assertEquals( Map.of( @@ -223,9 +228,10 @@ void shouldParseFilters() throws BadRequestException { } @Test - void shouldParseFiltersGivenRepeatedUID() throws BadRequestException { + void shouldParseAttributeFiltersGivenRepeatedUID() throws BadRequestException { Map> filters = - parseFilters(TEA_1_UID + ":lt:20," + TEA_2_UID + ":like:foo," + TEA_1_UID + ":gt:10"); + parseAttributeFilters( + TEA_1_UID + ":lt:20," + TEA_2_UID + ":like:foo," + TEA_1_UID + ":gt:10"); assertEquals( Map.of( @@ -238,22 +244,22 @@ void shouldParseFiltersGivenRepeatedUID() throws BadRequestException { } @Test - void shouldParseFiltersOnlyContainingAnIdentifier() throws BadRequestException { - Map> filters = parseFilters(TEA_1_UID.getValue()); + void shouldParseAttributeFiltersOnlyContainingAnIdentifier() throws BadRequestException { + Map> filters = parseAttributeFilters(TEA_1_UID.getValue()); assertEquals(Map.of(TEA_1_UID, List.of()), filters); } @Test - void shouldParseFiltersWithIdentifierAndTrailingColon() throws BadRequestException { - Map> filters = parseFilters(TEA_1_UID.getValue() + ":"); + void shouldParseAttributeFiltersWithIdentifierAndTrailingColon() throws BadRequestException { + Map> filters = parseAttributeFilters(TEA_1_UID.getValue() + ":"); assertEquals(Map.of(TEA_1_UID, List.of()), filters); } @Test - void shouldParseFiltersGivenBlankInput() throws BadRequestException { - Map> filters = parseFilters(" "); + void shouldParseAttributeFiltersGivenBlankInput() throws BadRequestException { + Map> filters = parseAttributeFilters(" "); assertTrue(filters.isEmpty()); } @@ -261,20 +267,21 @@ void shouldParseFiltersGivenBlankInput() throws BadRequestException { @Test void shouldFailParsingFiltersMissingAValue() { Exception exception = - assertThrows(BadRequestException.class, () -> parseFilters(TEA_1_UID + ":lt")); + assertThrows(BadRequestException.class, () -> parseAttributeFilters(TEA_1_UID + ":lt")); assertEquals("Query item or filter is invalid: " + TEA_1_UID + ":lt", exception.getMessage()); } @Test void shouldFailParsingFiltersWithMissingValueAndTrailingColon() { Exception exception = - assertThrows(BadRequestException.class, () -> parseFilters(TEA_1_UID + ":lt:")); + assertThrows(BadRequestException.class, () -> parseAttributeFilters(TEA_1_UID + ":lt:")); assertEquals("Query item or filter is invalid: " + TEA_1_UID + ":lt:", exception.getMessage()); } @Test - void shouldParseFiltersWithFilterNameHasSeparationCharInIt() throws BadRequestException { - Map> filters = parseFilters(TEA_2_UID + ":like:project/:x/:eq/:2"); + void shouldParseAttributeFiltersWithFilterNameHasSeparationCharInIt() throws BadRequestException { + Map> filters = + parseAttributeFilters(TEA_2_UID + ":like:project/:x/:eq/:2"); assertEquals( Map.of(TEA_2_UID, List.of(new QueryFilter(QueryOperator.LIKE, "project:x:eq:2"))), filters); @@ -283,7 +290,8 @@ void shouldParseFiltersWithFilterNameHasSeparationCharInIt() throws BadRequestEx @Test void shouldThrowBadRequestWhenFilterHasOperatorInWrongFormat() { BadRequestException exception = - assertThrows(BadRequestException.class, () -> parseFilters(TEA_1_UID + ":lke:value")); + assertThrows( + BadRequestException.class, () -> parseAttributeFilters(TEA_1_UID + ":lke:value")); assertEquals( "Query item or filter is invalid: " + TEA_1_UID + ":lke:value", exception.getMessage()); } @@ -292,7 +300,7 @@ void shouldThrowBadRequestWhenFilterHasOperatorInWrongFormat() { void shouldParseFilterWhenFilterHasDatesFormatDateWithMilliSecondsAndTimeZone() throws BadRequestException { Map> filters = - parseFilters( + parseAttributeFilters( TEA_1_UID + ":ge:2020-01-01T00/:00/:00.001 +05/:30:le:2021-01-01T00/:00/:00.001 +05/:30"); @@ -308,7 +316,7 @@ void shouldParseFilterWhenFilterHasDatesFormatDateWithMilliSecondsAndTimeZone() @Test void shouldParseFilterWhenFilterHasMultipleOperatorAndTextRange() throws BadRequestException { Map> filters = - parseFilters(TEA_1_UID + ":sw:project/:x:ew:project/:le/:"); + parseAttributeFilters(TEA_1_UID + ":sw:project/:x:ew:project/:le/:"); assertEquals( Map.of( @@ -322,7 +330,7 @@ void shouldParseFilterWhenFilterHasMultipleOperatorAndTextRange() throws BadRequ @Test void shouldParseFilterWhenMultipleFiltersAreMixedCommaAndSlash() throws BadRequestException { Map> filters = - parseFilters( + parseAttributeFilters( TEA_1_UID + ":eq:project///,/,//" + "," @@ -342,7 +350,8 @@ void shouldParseFilterWhenMultipleFiltersAreMixedCommaAndSlash() throws BadReque @Test void shouldParseFilterWhenFilterHasMultipleOperatorWithFinalColon() throws BadRequestException { - Map> filters = parseFilters(TEA_1_UID + ":like:value1/::like:value2"); + Map> filters = + parseAttributeFilters(TEA_1_UID + ":like:value1/::like:value2"); assertEquals( Map.of( @@ -353,6 +362,71 @@ void shouldParseFilterWhenFilterHasMultipleOperatorWithFinalColon() throws BadRe filters); } + @Test + void shouldParseDataElementFilters() throws BadRequestException { + Map> filters = + parseDataElementFilters(DE_1_UID + ":lt:20:gt:10," + DE_2_UID + ":like:foo"); + + assertEquals( + Map.of( + DE_1_UID, + List.of( + new QueryFilter(QueryOperator.LT, "20"), new QueryFilter(QueryOperator.GT, "10")), + DE_2_UID, + List.of(new QueryFilter(QueryOperator.LIKE, "foo"))), + filters); + } + + @Test + void shouldParseDataElementFilterWhenUsingExistenceOperator() throws BadRequestException { + Map> filters = parseDataElementFilters(DE_1_UID + ":ex:true"); + + assertEquals(Map.of(DE_1_UID, List.of(new QueryFilter(QueryOperator.EX, "true"))), filters); + } + + @Test + void shouldParseDataElementFilterWhenOnlyUIDSupplied() throws BadRequestException { + Map> filters = parseDataElementFilters(DE_1_UID.getValue()); + + assertEquals(Map.of(DE_1_UID, List.of(new QueryFilter(QueryOperator.EX, "true"))), filters); + } + + @Test + void shouldParseDataElementFiltersWhenUsingExistenceOperatorMoreThanOnceOnDifferentUIDs() + throws BadRequestException { + Map> filters = + parseDataElementFilters(DE_1_UID + ":ex:true," + DE_2_UID + ":ex:false"); + + assertEquals( + Map.of( + DE_1_UID, + List.of(new QueryFilter(QueryOperator.EX, "true")), + DE_2_UID, + List.of(new QueryFilter(QueryOperator.EX, "false"))), + filters); + } + + // @Test + void shouldFailParsingDataElementFiltersWhenFilteringSameUIDIfAtLeastOneIsExistenceOperator() { + Exception exception = + assertThrows( + BadRequestException.class, + () -> parseDataElementFilters(DE_1_UID + ":ex:true," + DE_1_UID + ":gt:10")); + assertContains( + "A filter with the operator 'EX' can only filter by a single value at a time", + exception.getMessage()); + } + + @Test + void shouldFailParsingDataElementFilterWhenUsingExistenceOperatorWithInvalidValue() { + Exception exception = + assertThrows( + BadRequestException.class, () -> parseDataElementFilters(DE_1_UID + ":ex:value")); + assertContains( + "A filter with the operator 'EX' can only have 'true' or 'false' as its value", + exception.getMessage()); + } + @ParameterizedTest @EnumSource( value = OrganisationUnitSelectionMode.class, From 0187a04c64eebff68d898979dab25f99eca8da2d Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 27 Jan 2025 15:36:29 +0100 Subject: [PATCH 08/20] feat: Assure EX operator is used only once in DE filter [DHIS2-15954] --- .../export/RequestParamsValidator.java | 23 +++++++++++++------ .../export/RequestParamsValidatorTest.java | 6 ++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java index 460cbb4b037e..d2377835cccc 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java @@ -414,18 +414,27 @@ private static void parseSanitizedDataElementFilters( private static void validateExistenceOperator( String[] filters, Map> result, String input, UID uid) throws BadRequestException { + boolean hasExistenceOperator = + result.containsKey(uid) + && result.get(uid).stream().anyMatch(qf -> qf.getOperator().equals(QueryOperator.EX)); + for (int i = 0; i < filters.length; i += 2) { - if (filters[i].equalsIgnoreCase(QueryOperator.EX.name())) { - if (!filters[1].equalsIgnoreCase("true") && !filters[1].equalsIgnoreCase("false")) { + String operator = filters[i]; + String value = (i + 1 < filters.length) ? filters[i + 1] : null; + + if (hasExistenceOperator + || (operator.equalsIgnoreCase(QueryOperator.EX.name()) && result.containsKey(uid))) { + throw new BadRequestException( + "A data element UID combined with the operator 'EX' cannot be used more than once in the same filter: " + + input); + } + + if (operator.equalsIgnoreCase(QueryOperator.EX.name())) { + if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { throw new BadRequestException( "A filter with the operator 'EX' can only have 'true' or 'false' as its value: " + input); } - if (result.containsKey(uid)) { - throw new BadRequestException( - "A filter with the operator 'EX' can only filter by a single value at a time: " - + input); - } } } } diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java index 72d7f425d419..39966da8c022 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java @@ -406,14 +406,14 @@ void shouldParseDataElementFiltersWhenUsingExistenceOperatorMoreThanOnceOnDiffer filters); } - // @Test + @Test void shouldFailParsingDataElementFiltersWhenFilteringSameUIDIfAtLeastOneIsExistenceOperator() { Exception exception = assertThrows( BadRequestException.class, - () -> parseDataElementFilters(DE_1_UID + ":ex:true," + DE_1_UID + ":gt:10")); + () -> parseDataElementFilters(DE_1_UID + ":ex:true," + DE_1_UID + ":gt:true")); assertContains( - "A filter with the operator 'EX' can only filter by a single value at a time", + "A data element UID combined with the operator 'EX' cannot be used more than once in the same filter", exception.getMessage()); } From 96625e580cf884db15a8ca4af7596d9f09808578 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 27 Jan 2025 15:45:41 +0100 Subject: [PATCH 09/20] feat: Filter by DE when fetching event file [DHIS2-15954] --- .../hisp/dhis/tracker/export/event/DefaultEventService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java index 1be03e3a7f7a..d443491f7cee 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java @@ -40,6 +40,8 @@ import org.hisp.dhis.common.IdentifiableObjectManager; import org.hisp.dhis.common.IdentifiableProperty; import org.hisp.dhis.common.OrganisationUnitSelectionMode; +import org.hisp.dhis.common.QueryFilter; +import org.hisp.dhis.common.QueryOperator; import org.hisp.dhis.common.UID; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.dataelement.DataElementService; @@ -120,7 +122,8 @@ private FileResource getFileResourceMetadata(UID eventUid, UID dataElementUid) .orgUnitMode(OrganisationUnitSelectionMode.ACCESSIBLE) .events(Set.of(eventUid)) .eventParams(EventParams.FALSE) - .dataElementFilters(Map.of(dataElementUid, List.of())) + .dataElementFilters( + Map.of(dataElementUid, List.of(new QueryFilter(QueryOperator.EX, "true")))) .build(); events = getEvents(operationParams, new PageParams(1, 1, false)); } catch (BadRequestException e) { From 0e0afadaaf303a1baab262c063edc5b3c5420252 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 27 Jan 2025 15:50:34 +0100 Subject: [PATCH 10/20] feat: Merge statements [DHIS2-15954] --- .../tracker/export/RequestParamsValidator.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java index d2377835cccc..7800fa5fdb19 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java @@ -429,12 +429,12 @@ private static void validateExistenceOperator( + input); } - if (operator.equalsIgnoreCase(QueryOperator.EX.name())) { - if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { - throw new BadRequestException( - "A filter with the operator 'EX' can only have 'true' or 'false' as its value: " - + input); - } + if (operator.equalsIgnoreCase(QueryOperator.EX.name()) + && !"true".equalsIgnoreCase(value) + && !"false".equalsIgnoreCase(value)) { + throw new BadRequestException( + "A filter with the operator 'EX' can only have 'true' or 'false' as its value: " + + input); } } } From 2b83bc065b65a5bf7583258bcb3ca0d282948e37 Mon Sep 17 00:00:00 2001 From: Marc Date: Tue, 28 Jan 2025 11:20:28 +0100 Subject: [PATCH 11/20] feat: Improve error message [DHIS2-15954] --- .../dhis/programstagefilter/EventDataFilter.java | 13 +++++++++++++ .../tracker/export/RequestParamsValidator.java | 2 +- .../tracker/export/RequestParamsValidatorTest.java | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/programstagefilter/EventDataFilter.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/programstagefilter/EventDataFilter.java index 47bb780f370b..08f6b6265716 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/programstagefilter/EventDataFilter.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/programstagefilter/EventDataFilter.java @@ -65,6 +65,9 @@ public class EventDataFilter implements Serializable { /** Like */ private String like; + /** Exists */ + private String ex; + /** If the dataItem is of type date, then date filtering parameters are specified using this. */ private DateFilterPeriod dateFilter; @@ -158,6 +161,16 @@ public void setLike(String like) { this.like = like; } + @JsonProperty + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + public String getEx() { + return ex; + } + + public void setEx(String ex) { + this.ex = ex; + } + @JsonProperty @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) public DateFilterPeriod getDateFilter() { diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java index 7800fa5fdb19..929516aa174e 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java @@ -425,7 +425,7 @@ private static void validateExistenceOperator( if (hasExistenceOperator || (operator.equalsIgnoreCase(QueryOperator.EX.name()) && result.containsKey(uid))) { throw new BadRequestException( - "A data element UID combined with the operator 'EX' cannot be used more than once in the same filter: " + "A data element UID filtering with the operator 'EX' cannot be combined with additional filter criteria: " + input); } diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java index 39966da8c022..6ee319e1055d 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java @@ -413,7 +413,7 @@ void shouldFailParsingDataElementFiltersWhenFilteringSameUIDIfAtLeastOneIsExiste BadRequestException.class, () -> parseDataElementFilters(DE_1_UID + ":ex:true," + DE_1_UID + ":gt:true")); assertContains( - "A data element UID combined with the operator 'EX' cannot be used more than once in the same filter", + "A data element UID filtering with the operator 'EX' cannot be combined with additional filter criteria", exception.getMessage()); } From c31ec3ee8472ff24621c2c70d8faca269034597a Mon Sep 17 00:00:00 2001 From: Marc Date: Wed, 29 Jan 2025 11:40:20 +0100 Subject: [PATCH 12/20] feat: Add test to verify mapping logic [DHIS2-15945] --- .../export/event/EventRequestParamsMapperTest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java index eeb707f33cc8..467e90b0da8e 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java @@ -470,6 +470,19 @@ void shouldMapDataElementFiltersWhenDataElementHasMultipleFilters() throws BadRe assertEquals(expected, dataElementFilters); } + @Test + void shouldMapDataElementFiltersWhenQueryFilterHasUIDOnly() throws BadRequestException { + EventRequestParams eventRequestParams = new EventRequestParams(); + eventRequestParams.setFilter(DE_1_UID.getValue()); + + EventOperationParams params = mapper.map(eventRequestParams, idSchemeParams); + + Map> dataElementFilters = params.getDataElementFilters(); + assertNotNull(dataElementFilters); + Map> expected = Map.of(DE_1_UID, List.of()); + assertEquals(expected, dataElementFilters); + } + @Test void shouldMapDataElementFiltersToDefaultIfNoneSet() throws BadRequestException { EventRequestParams eventRequestParams = new EventRequestParams(); From cdaf3ef5e12f0c65c2b33345c1e4ded09d070727 Mon Sep 17 00:00:00 2001 From: Marc Date: Wed, 29 Jan 2025 11:52:41 +0100 Subject: [PATCH 13/20] feat: Add test to verify mapping logic [DHIS2-15945] --- .../tracker/export/event/EventRequestParamsMapperTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java index 467e90b0da8e..871a94be82ff 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java @@ -479,7 +479,8 @@ void shouldMapDataElementFiltersWhenQueryFilterHasUIDOnly() throws BadRequestExc Map> dataElementFilters = params.getDataElementFilters(); assertNotNull(dataElementFilters); - Map> expected = Map.of(DE_1_UID, List.of()); + Map> expected = + Map.of(DE_1_UID, List.of(new QueryFilter(QueryOperator.EX, "true"))); assertEquals(expected, dataElementFilters); } From bad030e03bb8f60988c4c09e689f75b2007930d9 Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 30 Jan 2025 08:41:21 +0100 Subject: [PATCH 14/20] feat: Replace operator enum value [DHIS2-15945] --- .../src/main/java/org/hisp/dhis/common/QueryOperator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java index d92457a6124a..e664a6208649 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java @@ -51,7 +51,7 @@ public enum QueryOperator { IN("in", true), SW("sw"), EW("ew"), - EX("??"), + EX("ex"), // Analytics specifics IEQ("==", true), NE("!=", true), From 91a898f7af492559eda918e4e6f88c95894abea7 Mon Sep 17 00:00:00 2001 From: Marc Date: Sun, 9 Feb 2025 19:00:45 +0100 Subject: [PATCH 15/20] feat: Introduce tracker unary operators [DHIS2-15945] --- .../org/hisp/dhis/common/QueryFilter.java | 10 ++-- .../org/hisp/dhis/common/QueryOperator.java | 17 +++++- .../export/event/DefaultEventService.java | 2 +- .../event/EventOperationParamsMapperTest.java | 6 +- .../export/event/EventExporterTest.java | 52 ++++++++++++++-- .../export/RequestParamsValidatorTest.java | 60 ++++++++++++------- .../event/EventRequestParamsMapperTest.java | 2 +- 7 files changed, 110 insertions(+), 39 deletions(-) diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java index de7f26ebb453..49f21dc6da19 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java @@ -30,7 +30,6 @@ import static org.hisp.dhis.analytics.QueryKey.NV; import static org.hisp.dhis.common.QueryOperator.EQ; import static org.hisp.dhis.common.QueryOperator.EW; -import static org.hisp.dhis.common.QueryOperator.EX; import static org.hisp.dhis.common.QueryOperator.GE; import static org.hisp.dhis.common.QueryOperator.GT; import static org.hisp.dhis.common.QueryOperator.IEQ; @@ -44,6 +43,8 @@ import static org.hisp.dhis.common.QueryOperator.NIEQ; import static org.hisp.dhis.common.QueryOperator.NILIKE; import static org.hisp.dhis.common.QueryOperator.NLIKE; +import static org.hisp.dhis.common.QueryOperator.NNULL; +import static org.hisp.dhis.common.QueryOperator.NULL; import static org.hisp.dhis.common.QueryOperator.SW; import com.google.common.collect.ImmutableMap; @@ -81,7 +82,8 @@ public class QueryFilter { .put(EW, unused -> "like") .put(NLIKE, unused -> "not like") .put(IN, unused -> "in") - .put(EX, unused -> "??") + .put(NULL, unused -> "null") + .put(NNULL, unused -> "not null") .build(); protected QueryOperator operator; @@ -111,10 +113,6 @@ public boolean isFilter() { return operator != null && filter != null && !filter.isEmpty(); } - public boolean isOperator(QueryOperator op) { - return operator != null && operator.equals(op); - } - public String getSqlOperator() { return getSqlOperator(false); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java index e664a6208649..46dc6aa80727 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java @@ -51,7 +51,8 @@ public enum QueryOperator { IN("in", true), SW("sw"), EW("ew"), - EX("ex"), + NULL("??"), + NNULL("??"), // Analytics specifics IEQ("==", true), NE("!=", true), @@ -69,6 +70,10 @@ public enum QueryOperator { private static final Set COMPARISON_OPERATORS = EnumSet.of(GT, GE, LT, LE); + private static final Set UNARY_OPERATORS = EnumSet.of(NULL, NNULL); + + private static final Set NEGATION_OPERATORS = EnumSet.of(NULL); + private final String value; private final boolean nullAllowed; @@ -114,4 +119,14 @@ public boolean isIn() { public boolean isComparison() { return COMPARISON_OPERATORS.contains(this); } + + public boolean isUnary() { + return UNARY_OPERATORS.contains(this); + } + ; + + public boolean isNegatedUnary() { + return NEGATION_OPERATORS.contains(this); + } + ; } diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java index 24278751a969..19ffe7d439a6 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/DefaultEventService.java @@ -123,7 +123,7 @@ private FileResource getFileResourceMetadata(UID eventUid, UID dataElementUid) .events(Set.of(eventUid)) .eventParams(EventParams.FALSE) .dataElementFilters( - Map.of(dataElementUid, List.of(new QueryFilter(QueryOperator.EX, "true")))) + Map.of(dataElementUid, List.of(new QueryFilter(QueryOperator.NNULL)))) .build(); events = getEvents(operationParams, new PageParams(1, 1, false)); } catch (BadRequestException e) { diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/event/EventOperationParamsMapperTest.java b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/event/EventOperationParamsMapperTest.java index c235081c16d5..41024c176d3d 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/event/EventOperationParamsMapperTest.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/event/EventOperationParamsMapperTest.java @@ -364,7 +364,9 @@ void shouldMapDataElementFilters() throws BadRequestException, ForbiddenExceptio .dataElementFilters( Map.of( UID.of(DE_1_UID), - List.of(new QueryFilter(QueryOperator.EQ, "2")), + List.of( + new QueryFilter(QueryOperator.EQ, "2"), + new QueryFilter(QueryOperator.NNULL)), UID.of(DE_2_UID), List.of(new QueryFilter(QueryOperator.LIKE, "foo")))) .build(); @@ -376,7 +378,7 @@ void shouldMapDataElementFilters() throws BadRequestException, ForbiddenExceptio Map> expected = Map.of( de1, - List.of(new QueryFilter(QueryOperator.EQ, "2")), + List.of(new QueryFilter(QueryOperator.EQ, "2"), new QueryFilter(QueryOperator.NNULL)), de2, List.of(new QueryFilter(QueryOperator.LIKE, "foo"))); assertEquals(expected, dataElements); diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java index 264c913818a0..ab75179ba4b1 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/event/EventExporterTest.java @@ -591,7 +591,7 @@ void testExportEventsWhenFilteringByNumericDataElements() } @Test - void shouldFilterByEventsContainingGivenDataValuesWhenFilteringByExistence() + void shouldFilterByEventsContainingGivenDataValuesWhenFilteringByNonNullDataValues() throws ForbiddenException, BadRequestException { EventOperationParams params = EventOperationParams.builder() @@ -599,9 +599,9 @@ void shouldFilterByEventsContainingGivenDataValuesWhenFilteringByExistence() .dataElementFilters( Map.of( UID.of("GieVkTxp4HH"), - List.of(new QueryFilter(QueryOperator.EX, "true")), + List.of(new QueryFilter(QueryOperator.NNULL)), UID.of("GieVkTxp4HG"), - List.of(new QueryFilter(QueryOperator.EX, "true")))) + List.of(new QueryFilter(QueryOperator.NNULL)))) .build(); List events = getEvents(params); @@ -610,7 +610,7 @@ void shouldFilterByEventsContainingGivenDataValuesWhenFilteringByExistence() } @Test - void shouldFilterByEventsNotContainingGivenDataValueWhenFilteringByNonexistence() + void shouldFilterByEventsNotContainingGivenDataValueWhenFilteringByNullDataValues() throws ForbiddenException, BadRequestException { EventOperationParams params = EventOperationParams.builder() @@ -618,7 +618,7 @@ void shouldFilterByEventsNotContainingGivenDataValueWhenFilteringByNonexistence( .programStage(programStage) .eventParams(EventParams.FALSE) .dataElementFilters( - Map.of(UID.of("DATAEL00002"), List.of(new QueryFilter(QueryOperator.EX, "false")))) + Map.of(UID.of("DATAEL00002"), List.of(new QueryFilter(QueryOperator.NULL)))) .build(); List events = getEvents(params); @@ -626,6 +626,48 @@ void shouldFilterByEventsNotContainingGivenDataValueWhenFilteringByNonexistence( assertContainsOnly(List.of("pTzf9KYMk72"), events); } + @Test + void shouldFilterByEventsContainingGivenDataValueWhenCombiningUnaryAndBinaryOperatorsInFilter() + throws ForbiddenException, BadRequestException { + DataElement dataElement = dataElement(UID.of("DATAEL00005")); + EventOperationParams params = + operationParamsBuilder + .enrollments(UID.of("nxP7UnKhomJ", "TvctPPhpD8z")) + .programStage(programStage) + .dataElementFilters( + Map.of( + UID.of(dataElement), + List.of( + new QueryFilter(QueryOperator.IN, "option2"), + new QueryFilter(QueryOperator.NNULL)))) + .build(); + + List events = getEvents(params); + + assertContainsOnly(List.of("D9PbzJY8bJM"), events); + } + + @Test + void shouldFilterByEventsContainingGivenDataValueWhenCombiningTwoUnaryOperatorsInFilter() + throws ForbiddenException, BadRequestException { + EventOperationParams params = + EventOperationParams.builder() + .enrollments(UID.of("nxP7UnKhomJ", "TvctPPhpD8z")) + .programStage(programStage) + .eventParams(EventParams.FALSE) + .dataElementFilters( + Map.of( + UID.of("DATAEL00002"), + List.of( + new QueryFilter(QueryOperator.NNULL), + new QueryFilter(QueryOperator.NNULL)))) + .build(); + + List events = getEvents(params); + + assertContainsOnly(List.of("D9PbzJY8bJM"), events); + } + @Test void testEnrollmentEnrolledBeforeSetToBeforeFirstEnrolledAtDate() throws ForbiddenException, BadRequestException { diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java index 6ee319e1055d..8e6a03d78657 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java @@ -268,14 +268,18 @@ void shouldParseAttributeFiltersGivenBlankInput() throws BadRequestException { void shouldFailParsingFiltersMissingAValue() { Exception exception = assertThrows(BadRequestException.class, () -> parseAttributeFilters(TEA_1_UID + ":lt")); - assertEquals("Query item or filter is invalid: " + TEA_1_UID + ":lt", exception.getMessage()); + assertEquals( + "Operator in filter must be be used with a value: " + TEA_1_UID + ":lt", + exception.getMessage()); } @Test void shouldFailParsingFiltersWithMissingValueAndTrailingColon() { Exception exception = assertThrows(BadRequestException.class, () -> parseAttributeFilters(TEA_1_UID + ":lt:")); - assertEquals("Query item or filter is invalid: " + TEA_1_UID + ":lt:", exception.getMessage()); + assertEquals( + "Operator in filter must be be used with a value: " + TEA_1_UID + ":lt:", + exception.getMessage()); } @Test @@ -378,53 +382,63 @@ void shouldParseDataElementFilters() throws BadRequestException { } @Test - void shouldParseDataElementFilterWhenUsingExistenceOperator() throws BadRequestException { - Map> filters = parseDataElementFilters(DE_1_UID + ":ex:true"); + void shouldParseDataElementFilterWhenSingleUnaryOperator() throws BadRequestException { + Map> filters = parseDataElementFilters(DE_1_UID + ":!null"); - assertEquals(Map.of(DE_1_UID, List.of(new QueryFilter(QueryOperator.EX, "true"))), filters); + assertEquals(Map.of(DE_1_UID, List.of(new QueryFilter(QueryOperator.NNULL))), filters); } @Test - void shouldParseDataElementFilterWhenOnlyUIDSupplied() throws BadRequestException { - Map> filters = parseDataElementFilters(DE_1_UID.getValue()); + void shouldParseDataElementFilterWhenMultipleUnaryOperatorsCombined() throws BadRequestException { + Map> filters = parseDataElementFilters(DE_1_UID + ":!null:null"); - assertEquals(Map.of(DE_1_UID, List.of(new QueryFilter(QueryOperator.EX, "true"))), filters); + assertEquals( + Map.of( + DE_1_UID, + List.of(new QueryFilter(QueryOperator.NNULL), new QueryFilter(QueryOperator.NULL))), + filters); } @Test - void shouldParseDataElementFiltersWhenUsingExistenceOperatorMoreThanOnceOnDifferentUIDs() + void shouldParseDataElementFilterWhenUnaryAndBinaryOperatorsCombined() throws BadRequestException { - Map> filters = - parseDataElementFilters(DE_1_UID + ":ex:true," + DE_2_UID + ":ex:false"); + Map> filters = parseDataElementFilters(DE_1_UID + ":null:gt:10"); assertEquals( Map.of( DE_1_UID, - List.of(new QueryFilter(QueryOperator.EX, "true")), - DE_2_UID, - List.of(new QueryFilter(QueryOperator.EX, "false"))), + List.of(new QueryFilter(QueryOperator.NULL), new QueryFilter(QueryOperator.GT, "10"))), filters); } @Test - void shouldFailParsingDataElementFiltersWhenFilteringSameUIDIfAtLeastOneIsExistenceOperator() { + void shouldFailParsingDataElementFiltersWhenUnaryOperatorContainsValue() { + Exception exception = + assertThrows( + BadRequestException.class, () -> parseDataElementFilters(DE_1_UID + ":!null:value")); + assertEquals( + "Query item or filter is invalid: " + DE_1_UID + ":!null:value", exception.getMessage()); + } + + @Test + void + shouldFailParsingDataElementFiltersWhenUnaryAndBinaryOperatorsCombinedAndUnaryContainsValue() { Exception exception = assertThrows( BadRequestException.class, - () -> parseDataElementFilters(DE_1_UID + ":ex:true," + DE_1_UID + ":gt:true")); - assertContains( - "A data element UID filtering with the operator 'EX' cannot be combined with additional filter criteria", + () -> parseDataElementFilters(DE_1_UID + ":gt:10:null:value")); + assertEquals( + "Operator in filter can't be used with a value: " + DE_1_UID + ":gt:10:null:value", exception.getMessage()); } @Test - void shouldFailParsingDataElementFilterWhenUsingExistenceOperatorWithInvalidValue() { + void shouldFailParsingDataElementFilterWhenMultipleBinaryOperatorsAndOneHasNoValue() { Exception exception = assertThrows( - BadRequestException.class, () -> parseDataElementFilters(DE_1_UID + ":ex:value")); - assertContains( - "A filter with the operator 'EX' can only have 'true' or 'false' as its value", - exception.getMessage()); + BadRequestException.class, () -> parseDataElementFilters(DE_1_UID + ":gt:10:lt")); + assertEquals( + "Query item or filter is invalid: " + DE_1_UID + ":gt:10:lt", exception.getMessage()); } @ParameterizedTest diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java index 871a94be82ff..8ab3b68ce38b 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventRequestParamsMapperTest.java @@ -480,7 +480,7 @@ void shouldMapDataElementFiltersWhenQueryFilterHasUIDOnly() throws BadRequestExc Map> dataElementFilters = params.getDataElementFilters(); assertNotNull(dataElementFilters); Map> expected = - Map.of(DE_1_UID, List.of(new QueryFilter(QueryOperator.EX, "true"))); + Map.of(DE_1_UID, List.of(new QueryFilter(QueryOperator.NNULL))); assertEquals(expected, dataElementFilters); } From 036b4590288c46e57b19200cf1342ba6745f99ca Mon Sep 17 00:00:00 2001 From: Marc Date: Sun, 9 Feb 2025 19:16:36 +0100 Subject: [PATCH 16/20] feat: Introduce tracker unary operators (2) [DHIS2-15945] --- .../tracker/export/event/JdbcEventStore.java | 43 ++++--- .../export/RequestParamsValidator.java | 108 ++++++++++++------ 2 files changed, 91 insertions(+), 60 deletions(-) diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java index 04211fb083c0..d0e57efc6002 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java @@ -1375,9 +1375,10 @@ private StringBuilder dataElementAndFiltersSql( .append(" as ") .append(deUid); - if (filterContainsExistenceOperator(filters)) { - // Operator EX allows for only one item in the filter list and its value is true or false - eventDataValuesWhereSql.append(addExistsFilterCondition(filters.get(0), hlp, deUid)); + if (filterContainsOnlyUnaryOperator(filters)) { + for (QueryFilter filter : filters) { + eventDataValuesWhereSql.append(unaryOperatorCondition(filter.getOperator(), hlp, deUid)); + } break; } @@ -1418,7 +1419,10 @@ private StringBuilder dataElementAndFiltersSql( String bindParameter = "parameter_" + filterCount; int itemType = de.getValueType().isNumeric() ? Types.NUMERIC : Types.VARCHAR; - if (!de.hasOptionSet()) { + if (filter.getOperator().isUnary()) { + eventDataValuesWhereSql.append( + unaryOperatorCondition(filter.getOperator(), hlp, deUid)); + } else if (!de.hasOptionSet()) { eventDataValuesWhereSql.append(hlp.whereAnd()); if (QueryOperator.IN.getValue().equalsIgnoreCase(filter.getSqlOperator())) { @@ -1468,11 +1472,6 @@ private StringBuilder dataElementAndFiltersSql( } } } - } else { - eventDataValuesWhereSql.append(hlp.whereAnd()); - eventDataValuesWhereSql.append(" (ev.eventdatavalues ?? '"); - eventDataValuesWhereSql.append(deUid); - eventDataValuesWhereSql.append("')"); } } @@ -1482,20 +1481,20 @@ private StringBuilder dataElementAndFiltersSql( .append(" "); } - private String addExistsFilterCondition(QueryFilter queryFilter, SqlHelper hlp, String deUid) { - StringBuilder existsBuilder = new StringBuilder(); + private String unaryOperatorCondition(QueryOperator queryOperator, SqlHelper hlp, String deUid) { + StringBuilder builder = new StringBuilder(); - existsBuilder.append(hlp.whereAnd()); - if (!Boolean.parseBoolean(queryFilter.getFilter())) { - existsBuilder.append(" not "); + builder.append(hlp.whereAnd()); + if (queryOperator.isNegatedUnary()) { + builder.append(" not "); } - existsBuilder.append(" (ev.eventdatavalues "); - existsBuilder.append(queryFilter.getSqlOperator()); - existsBuilder.append(" '"); - existsBuilder.append(deUid); - existsBuilder.append("')"); + builder.append(" (ev.eventdatavalues "); + builder.append(queryOperator.getValue()); + builder.append(" '"); + builder.append(deUid); + builder.append("') "); - return existsBuilder.toString(); + return builder.toString(); } private String inCondition(QueryFilter filter, String boundParameter, String queryCol) { @@ -1512,8 +1511,8 @@ private String inCondition(QueryFilter filter, String boundParameter, String que .toString(); } - private boolean filterContainsExistenceOperator(List filters) { - return filters.stream().anyMatch(qf -> qf.getOperator().equals(QueryOperator.EX)); + private boolean filterContainsOnlyUnaryOperator(List filters) { + return filters.stream().allMatch(qf -> qf.getOperator().isUnary()); } private String eventStatusSql( diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java index 929516aa174e..8d233c00839e 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java @@ -37,11 +37,13 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.regex.Pattern; @@ -401,61 +403,80 @@ private static void parseSanitizedDataElementFilters( if (uidIndex == 0 || input.length() == uidIndex) { UID uid = UID.of(input.replace(DIMENSION_NAME_SEP, "")); - result.putIfAbsent(uid, List.of(new QueryFilter(QueryOperator.EX, "true"))); + result.putIfAbsent(uid, List.of(new QueryFilter(QueryOperator.NNULL))); return; } + UID uid = UID.of(input.substring(0, uidIndex - 1)); String[] filters = FILTER_ITEM_SPLIT.split(input.substring(uidIndex)); - validateExistenceOperator(filters, result, input, uid); result.putIfAbsent(uid, new ArrayList<>()); validateFilterLength(filters, result, uid, input); } - private static void validateExistenceOperator( - String[] filters, Map> result, String input, UID uid) + private static void validateFilterLength( + String[] filters, Map> result, UID uid, String input) throws BadRequestException { - boolean hasExistenceOperator = - result.containsKey(uid) - && result.get(uid).stream().anyMatch(qf -> qf.getOperator().equals(QueryOperator.EX)); + switch (filters.length) { + case 1 -> addQueryFilter(result, uid, filters[0], null, input); + case 2 -> handleOperators(filters, result, uid, input); + case 3 -> handleMixedOperators(filters, result, uid, input); + case 4 -> handleMultipleBinaryOperators(filters, result, uid, input); + default -> throw new BadRequestException(INVALID_FILTER + input); + } + } - for (int i = 0; i < filters.length; i += 2) { - String operator = filters[i]; - String value = (i + 1 < filters.length) ? filters[i + 1] : null; + private static void addQueryFilter( + Map> result, UID uid, String operator, String value, String input) + throws BadRequestException { + result.get(uid).add(operatorValueQueryFilter(operator, value, input)); + } - if (hasExistenceOperator - || (operator.equalsIgnoreCase(QueryOperator.EX.name()) && result.containsKey(uid))) { - throw new BadRequestException( - "A data element UID filtering with the operator 'EX' cannot be combined with additional filter criteria: " - + input); - } + private static void handleOperators( + String[] filters, Map> result, UID uid, String input) + throws BadRequestException { + Optional firstOperator = findQueryOperatorFromFilter(filters[0]); + if (firstOperator.map(qo -> !qo.isUnary()).orElse(false)) { + addQueryFilter(result, uid, filters[0], filters[1], input); + } else { + addQueryFilter(result, uid, filters[0], null, input); + addQueryFilter(result, uid, filters[1], null, input); + } + } - if (operator.equalsIgnoreCase(QueryOperator.EX.name()) - && !"true".equalsIgnoreCase(value) - && !"false".equalsIgnoreCase(value)) { - throw new BadRequestException( - "A filter with the operator 'EX' can only have 'true' or 'false' as its value: " - + input); - } + private static void handleMixedOperators( + String[] filters, Map> result, UID uid, String input) + throws BadRequestException { + Optional firstOperator = findQueryOperatorFromFilter(filters[0]); + if (firstOperator.map(QueryOperator::isUnary).orElse(false)) { + addQueryFilter(result, uid, filters[0], null, input); + addQueryFilter(result, uid, filters[1], filters[2], input); + return; + } + + Optional thirdOperator = findQueryOperatorFromFilter(filters[2]); + if (thirdOperator.map(QueryOperator::isUnary).orElse(false)) { + addQueryFilter(result, uid, filters[0], filters[1], input); + addQueryFilter(result, uid, filters[2], null, input); + return; } + + throw new BadRequestException(INVALID_FILTER + input); } - private static void validateFilterLength( + private static void handleMultipleBinaryOperators( String[] filters, Map> result, UID uid, String input) throws BadRequestException { - // single operator - if (filters.length == 2) { - result.get(uid).add(operatorValueQueryFilter(filters[0], filters[1], input)); - } - // multiple operator - else if (filters.length == 4) { - for (int i = 0; i < filters.length; i += 2) { - result.get(uid).add(operatorValueQueryFilter(filters[i], filters[i + 1], input)); - } - } else { - throw new BadRequestException(INVALID_FILTER + input); + for (int i = 0; i < filters.length; i += 2) { + addQueryFilter(result, uid, filters[i], filters[i + 1], input); } } + public static Optional findQueryOperatorFromFilter(String filter) { + return Arrays.stream(QueryOperator.values()) + .filter(qo -> qo.name().equalsIgnoreCase(filter)) + .findFirst(); + } + public static OrganisationUnitSelectionMode validateOrgUnitModeForTrackedEntities( Set orgUnits, OrganisationUnitSelectionMode orgUnitMode, Set trackedEntities) throws BadRequestException { @@ -575,16 +596,27 @@ public static void validateUnsupportedParameter( private static QueryFilter operatorValueQueryFilter(String operator, String value, String filter) throws BadRequestException { - if (StringUtils.isEmpty(operator) || StringUtils.isEmpty(value)) { + if (StringUtils.isEmpty(operator)) { throw new BadRequestException(INVALID_FILTER + filter); } + QueryOperator queryOperator; try { - return new QueryFilter(QueryOperator.fromString(operator), escapedFilterValue(value)); - + queryOperator = QueryOperator.fromString(operator); } catch (IllegalArgumentException exception) { throw new BadRequestException(INVALID_FILTER + filter); } + + if (queryOperator.isUnary()) { + if (!StringUtils.isEmpty(value)) { + throw new BadRequestException("Operator in filter can't be used with a value: " + filter); + } + return new QueryFilter(queryOperator); + } else if (StringUtils.isEmpty(value)) { + throw new BadRequestException("Operator in filter must be be used with a value: " + filter); + } + + return new QueryFilter(queryOperator, escapedFilterValue(value)); } /** From edc5a564e8cd5fefefa838c616812d7e10b0c150 Mon Sep 17 00:00:00 2001 From: Marc Date: Sun, 9 Feb 2025 20:00:42 +0100 Subject: [PATCH 17/20] feat: Check for null operator [DHIS2-15945] --- .../src/main/java/org/hisp/dhis/common/QueryOperator.java | 2 -- .../controller/tracker/export/RequestParamsValidator.java | 8 +++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java index 46dc6aa80727..ef7b724d4a30 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java @@ -123,10 +123,8 @@ public boolean isComparison() { public boolean isUnary() { return UNARY_OPERATORS.contains(this); } - ; public boolean isNegatedUnary() { return NEGATION_OPERATORS.contains(this); } - ; } diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java index 8d233c00839e..538374791db7 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidator.java @@ -607,12 +607,18 @@ private static QueryFilter operatorValueQueryFilter(String operator, String valu throw new BadRequestException(INVALID_FILTER + filter); } + if (queryOperator == null) { + throw new BadRequestException(INVALID_FILTER + filter); + } + if (queryOperator.isUnary()) { if (!StringUtils.isEmpty(value)) { throw new BadRequestException("Operator in filter can't be used with a value: " + filter); } return new QueryFilter(queryOperator); - } else if (StringUtils.isEmpty(value)) { + } + + if (StringUtils.isEmpty(value)) { throw new BadRequestException("Operator in filter must be be used with a value: " + filter); } From 768adbf1a32daf1d2f874314e679a4f099a63232 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 10 Feb 2025 16:10:51 +0100 Subject: [PATCH 18/20] feat: Save unary operators in working list [DHIS2-15945] --- .../org/hisp/dhis/common/QueryOperator.java | 2 +- .../programstagefilter/EventDataFilter.java | 26 ++++++++++++++----- .../tracker/export/event/JdbcEventStore.java | 2 +- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java index ef7b724d4a30..69533276d6d0 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java @@ -124,7 +124,7 @@ public boolean isUnary() { return UNARY_OPERATORS.contains(this); } - public boolean isNegatedUnary() { + public boolean isNegatedOperator() { return NEGATION_OPERATORS.contains(this); } } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/programstagefilter/EventDataFilter.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/programstagefilter/EventDataFilter.java index 08f6b6265716..171ddce901f5 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/programstagefilter/EventDataFilter.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/programstagefilter/EventDataFilter.java @@ -65,8 +65,11 @@ public class EventDataFilter implements Serializable { /** Like */ private String like; - /** Exists */ - private String ex; + /** Null */ + private String isNull; + + /** Not Null */ + private String isNotNull; /** If the dataItem is of type date, then date filtering parameters are specified using this. */ private DateFilterPeriod dateFilter; @@ -163,12 +166,23 @@ public void setLike(String like) { @JsonProperty @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) - public String getEx() { - return ex; + public String getNull() { + return isNull; + } + + public void setNull(String isNull) { + this.isNull = isNull == null ? "" : isNull; + } + + @JsonProperty("!null") + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + public String getNotNull() { + return isNotNull; } - public void setEx(String ex) { - this.ex = ex; + @JsonProperty("!null") + public void setNotNull(String isNotNull) { + this.isNotNull = isNotNull == null ? "" : isNotNull; } @JsonProperty diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java index d0e57efc6002..b27aff5d3383 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java @@ -1485,7 +1485,7 @@ private String unaryOperatorCondition(QueryOperator queryOperator, SqlHelper hlp StringBuilder builder = new StringBuilder(); builder.append(hlp.whereAnd()); - if (queryOperator.isNegatedUnary()) { + if (queryOperator.isNegatedOperator()) { builder.append(" not "); } builder.append(" (ev.eventdatavalues "); From 06cfe9394cd836aacdcf8acb6de308433312d7cd Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 10 Feb 2025 20:34:20 +0100 Subject: [PATCH 19/20] feat: Use is null instead of jsonb specific ?? operator [DHIS2-15945] --- .../org/hisp/dhis/common/QueryFilter.java | 4 ++-- .../org/hisp/dhis/common/QueryOperator.java | 10 ++------- .../tracker/export/event/JdbcEventStore.java | 22 ++++++++----------- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java index 49f21dc6da19..3df61fc07b9a 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryFilter.java @@ -82,8 +82,8 @@ public class QueryFilter { .put(EW, unused -> "like") .put(NLIKE, unused -> "not like") .put(IN, unused -> "in") - .put(NULL, unused -> "null") - .put(NNULL, unused -> "not null") + .put(NULL, unused -> "is null") + .put(NNULL, unused -> "is not null") .build(); protected QueryOperator operator; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java index 69533276d6d0..c87c749281f6 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/QueryOperator.java @@ -51,8 +51,8 @@ public enum QueryOperator { IN("in", true), SW("sw"), EW("ew"), - NULL("??"), - NNULL("??"), + NULL("is null"), + NNULL("is not null"), // Analytics specifics IEQ("==", true), NE("!=", true), @@ -72,8 +72,6 @@ public enum QueryOperator { private static final Set UNARY_OPERATORS = EnumSet.of(NULL, NNULL); - private static final Set NEGATION_OPERATORS = EnumSet.of(NULL); - private final String value; private final boolean nullAllowed; @@ -123,8 +121,4 @@ public boolean isComparison() { public boolean isUnary() { return UNARY_OPERATORS.contains(this); } - - public boolean isNegatedOperator() { - return NEGATION_OPERATORS.contains(this); - } } diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java index b27aff5d3383..c23476d2e12b 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/JdbcEventStore.java @@ -1482,19 +1482,15 @@ private StringBuilder dataElementAndFiltersSql( } private String unaryOperatorCondition(QueryOperator queryOperator, SqlHelper hlp, String deUid) { - StringBuilder builder = new StringBuilder(); - - builder.append(hlp.whereAnd()); - if (queryOperator.isNegatedOperator()) { - builder.append(" not "); - } - builder.append(" (ev.eventdatavalues "); - builder.append(queryOperator.getValue()); - builder.append(" '"); - builder.append(deUid); - builder.append("') "); - - return builder.toString(); + return new StringBuilder() + .append(hlp.whereAnd()) + .append(" ev.eventdatavalues->") + .append("'") + .append(deUid) + .append("' ") + .append(queryOperator.getValue()) + .append(" ") + .toString(); } private String inCondition(QueryFilter filter, String boundParameter, String queryCol) { From b999c40e378f30ce8c9ff872a3f20b3bdaead645 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 10 Feb 2025 21:04:41 +0100 Subject: [PATCH 20/20] feat: Add test to validate filter with UID only is mapped [DHIS2-15945] --- .../tracker/export/RequestParamsValidatorTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java index 8e6a03d78657..02d0748e9608 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/RequestParamsValidatorTest.java @@ -381,6 +381,13 @@ void shouldParseDataElementFilters() throws BadRequestException { filters); } + @Test + void shouldParseDataElementFilterWhenOnlyUIDProvided() throws BadRequestException { + Map> filters = parseDataElementFilters(DE_1_UID.getValue()); + + assertEquals(Map.of(DE_1_UID, List.of(new QueryFilter(QueryOperator.NNULL))), filters); + } + @Test void shouldParseDataElementFilterWhenSingleUnaryOperator() throws BadRequestException { Map> filters = parseDataElementFilters(DE_1_UID + ":!null");