Skip to content

feat: Introduce preferred search operators on TEA [DHIS2-12183] #21376

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 18, 2025
Merged
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 @@ -33,6 +33,7 @@
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.replaceOnce;

import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
import lombok.Getter;
Expand Down Expand Up @@ -148,4 +149,11 @@ public boolean isBinary() {
public boolean isUnary() {
return UNARY_OPERATORS.contains(this);
}

private static final Set<QueryOperator> TRACKER_OPERATORS =
EnumSet.of(EQ, GT, GE, LT, LE, LIKE, IN, SW, EW, NULL, NNULL);

public static Set<QueryOperator> getTrackerOperators() {
return Collections.unmodifiableSet(TRACKER_OPERATORS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ public enum ErrorCode {
E4079(
"Program `{0}` category mapping `{1}` has multiple option mappings for Category Option `{2}`"),
E4080("Program `{0}` category mapping `{1}` has an invalid option mapping `{1}`"),
E4081("The provided preferred TEA operator `{0}` is not part of the tracker operators `{1}`"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
E4081("The provided preferred TEA operator `{0}` is not part of the tracker operators `{1}`"),
E4081("The provided preferred TEA operator `{0}` is not part of supported tracker operators `{1}`"),


/* SQL views */
E4300("SQL query is null"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import org.hisp.dhis.common.IdentifiableObject;
import org.hisp.dhis.common.MetadataObject;
import org.hisp.dhis.common.ObjectStyle;
import org.hisp.dhis.common.QueryOperator;
import org.hisp.dhis.common.ValueType;
import org.hisp.dhis.common.ValueTypedDimensionalItemObject;
import org.hisp.dhis.option.OptionSet;
Expand Down Expand Up @@ -101,6 +102,8 @@ public class TrackedEntityAttribute extends BaseDimensionalItemObject

private int minCharactersToSearch;

private QueryOperator preferredSearchOperator;

// -------------------------------------------------------------------------
// Constructors
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -381,6 +384,16 @@ public void setMinCharactersToSearch(int minCharactersToSearch) {
this.minCharactersToSearch = minCharactersToSearch;
}

@JsonProperty
@JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0)
public QueryOperator getPreferredSearchOperator() {
return preferredSearchOperator;
}

public void setPreferredSearchOperator(QueryOperator preferredSearchOperator) {
this.preferredSearchOperator = preferredSearchOperator;
}

@JsonProperty
@JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0)
public String getFieldMask() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@

<property name="minCharactersToSearch" column="mincharacterstosearch" />

<property name="preferredSearchOperator" column="preferredsearchoperator">
<type name="org.hibernate.type.EnumType">
<param name="enumClass">org.hisp.dhis.common.QueryOperator</param>
<param name="useNamed">true</param>
</type>
</property>

</class>

</hibernate-mapping>
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
*/
package org.hisp.dhis.dxf2.metadata.objectbundle.hooks;

import static org.hisp.dhis.common.QueryOperator.getTrackerOperators;

import java.util.function.Consumer;
import org.hisp.dhis.common.Objects;
import org.hisp.dhis.common.ValueType;
Expand Down Expand Up @@ -71,6 +73,18 @@ public void validate(
"Not a valid TextPattern 'TEXT' segment."));
}
}

if (attr.getPreferredSearchOperator() != null
&& !getTrackerOperators().contains(attr.getPreferredSearchOperator())) {
addReports.accept(
new ErrorReport(
TrackedEntityAttribute.class,
ErrorCode.E4081,
attr.getPreferredSearchOperator(),
getTrackerOperators()));
}

// TODO(tracker) Validate the preferred operator is part of the allowed operators
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ private void mapAttributeFilters(
|| queryFilter.getFilter().length() < tea.getMinCharactersToSearch())) {
throw new IllegalQueryException(
String.format(
"At least %d character(s) should be present in the filter to start a search, but the filter for operator %s doesn't contain enough.",
tea.getMinCharactersToSearch(), queryFilter.getOperator()));
"At least %d character(s) should be present in the filter to start a search, but the filter for the TEA %s doesn't contain enough.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicky: Do we actually use TEA and TET in error messages? Or do we want to use the full "Tracked Entity Attribute" ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll change the message in the coming PR

tea.getMinCharactersToSearch(), tea.getUid()));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,8 @@ void shouldFailWhenTeaMinCharactersSetAndNotReached()
Assertions.assertThrows(
IllegalQueryException.class, () -> mapper.map(trackedEntityOperationParams, user));
assertContains(
"At least 2 character(s) should be present in the filter to start a search, but the filter for operator EQ",
"At least 2 character(s) should be present in the filter to start a search, but the filter for the TEA "
+ TEA_1_UID,
exception.getMessage());
}

Expand All @@ -588,7 +589,8 @@ void shouldFailWhenTeaMinCharactersSetWithMultipleFiltersAndNotAllReachTheMinimu
Assertions.assertThrows(
IllegalQueryException.class, () -> mapper.map(trackedEntityOperationParams, user));
assertContains(
"At least 2 character(s) should be present in the filter to start a search, but the filter for operator LIKE",
"At least 2 character(s) should be present in the filter to start a search, but the filter for the TEA "
+ TEA_1_UID,
exception.getMessage());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Migration script to add the field preferred operator in the trackedentityattribute table
alter table trackedentityattribute
Copy link
Contributor

@ameenhere ameenhere Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you migrate from a 2.41 instance directly into this branch build and see if any issue show up?
The reason I am asking is, if we have separate migration files dealing with the same alter table , there might be issues with keeping all of it in a single transaction. So just try it out. The test should cover running both this migration, and the minsearchcharacters migration in a single migration attempt.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During development I tried a few times and it worked.
I imported a 2.41 database (no minsearchcharacters no preferredsearchoperator present), and after the migration, both fields are present in the database.

add column if not exists preferredsearchoperator varchar(20) default null;
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,24 @@
*/
package org.hisp.dhis.tracker.imports.bundle;

import static org.hisp.dhis.common.QueryOperator.EQ;
import static org.hisp.dhis.common.QueryOperator.IEQ;
import static org.hisp.dhis.common.QueryOperator.LIKE;
import static org.hisp.dhis.test.utils.Assertions.assertStartsWith;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.hisp.dhis.common.IdentifiableObjectManager;
import org.hisp.dhis.common.QueryOperator;
import org.hisp.dhis.dxf2.metadata.MetadataImportParams;
import org.hisp.dhis.dxf2.metadata.MetadataImportService;
import org.hisp.dhis.dxf2.metadata.MetadataObjects;
import org.hisp.dhis.dxf2.metadata.feedback.ImportReport;
import org.hisp.dhis.feedback.ErrorReport;
import org.hisp.dhis.feedback.Status;
import org.hisp.dhis.organisationunit.OrganisationUnit;
import org.hisp.dhis.test.integration.PostgresIntegrationTestBase;
import org.hisp.dhis.trackedentity.TrackedEntity;
Expand Down Expand Up @@ -71,6 +83,8 @@ class TrackedEntityAttributeTest extends PostgresIntegrationTestBase {

@Autowired private IdentifiableObjectManager manager;

@Autowired private MetadataImportService metadataImportService;

@BeforeAll
void setUp() throws IOException {
testSetup.importMetadata("tracker/te_with_tea_metadata.json");
Expand Down Expand Up @@ -115,6 +129,45 @@ void shouldSetMinCharactersToSearchFromImportOrDefaultToZeroIfNotSpecified() {
assertMinCharactersToSearch(trackedEntityAttributes, "TsfP85GKsU5", 0);
}

@Test
void shouldSetPreferredSearchOperatorFromImportOrNullIfNotSpecified() {
List<TrackedEntityAttribute> trackedEntityAttributes =
trackedEntityAttributeService.getAllTrackedEntityAttributes();

assertPreferredSearchOperator(trackedEntityAttributes, "sTGqP5JNy6E", LIKE);
assertPreferredSearchOperator(trackedEntityAttributes, "sYn3tkL3XKa", EQ);
assertPreferredSearchOperator(trackedEntityAttributes, "TsfP85GKsU5", null);
}

@Test
void shouldFailIfPreferredSearchOperatorIsNotPartOfTrackerOperators() {
TrackedEntityAttribute tea =
trackedEntityAttributeService.getTrackedEntityAttribute("sYn3tkL3XKa");
tea.setPreferredSearchOperator(IEQ);

ImportReport report =
metadataImportService.importMetadata(
new MetadataImportParams(),
new MetadataObjects(Map.of(TrackedEntityAttribute.class, List.of(tea))));

assertEquals(Status.ERROR, report.getStatus());
String message =
report.getTypeReports().stream()
.findFirst()
.flatMap(
typeReport ->
typeReport.getObjectReports().stream()
.findFirst()
.flatMap(
objectReport ->
objectReport.getErrorReports().stream()
.findFirst()
.map(ErrorReport::getMessage)))
.orElseThrow();
assertStartsWith(
"The provided preferred TEA operator `IEQ` is not part of the tracker operators", message);
}

private void assertMinCharactersToSearch(
List<TrackedEntityAttribute> teas, String uid, int expected) {
TrackedEntityAttribute tea =
Expand All @@ -129,4 +182,19 @@ private void assertMinCharactersToSearch(
tea.getMinCharactersToSearch(),
"Expected minCharactersToSearch for UID " + uid + " to be " + expected);
}

private void assertPreferredSearchOperator(
List<TrackedEntityAttribute> teas, String uid, QueryOperator expected) {
TrackedEntityAttribute tea =
teas.stream()
.filter(t -> t.getUid().equals(uid))
.findFirst()
.orElseThrow(
() -> new AssertionError("TrackedEntityAttribute with UID " + uid + " not found"));

assertEquals(
expected,
tea.getPreferredSearchOperator(),
"Expected preferredSearchOperator for UID " + uid + " to be " + expected);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"attributeValues": [],
"legendSets": [],
"minCharactersToSearch": 2,
"preferredSearchOperator": "LIKE",
"sharing": {
"public": "rw------",
"external": false,
Expand Down Expand Up @@ -90,6 +91,7 @@
"translations": [],
"attributeValues": [],
"legendSets": [],
"preferredSearchOperator": "EQ",
"sharing": {
"public": "rw------",
"external": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import java.util.Set;
import java.util.stream.Collectors;
import org.hisp.dhis.common.IdentifiableObjectManager;
import org.hisp.dhis.common.QueryOperator;
import org.hisp.dhis.common.ValueType;
import org.hisp.dhis.http.HttpStatus;
import org.hisp.dhis.jsontree.JsonObject;
Expand Down Expand Up @@ -286,6 +287,18 @@ void shouldNotFailIfNoIndexableAttributesAreConfigured() {
assertAttributeList(json, Set.of());
}

@Test
void shouldContainPreferredSearchOperatorWhenSet() {
teaA.setPreferredSearchOperator(QueryOperator.LIKE);
manager.update(teaA);

JsonObject json =
GET("/trackedEntityAttributes?indexableOnly=true&filter=name:in:[AttributeA]&fields=*")
.content(HttpStatus.OK);

assertAttributePreferredOperator(json, Set.of("LIKE"));
}

private static void assertAttributeList(JsonObject actualJson, Set<String> expected) {
assertFalse(actualJson.isEmpty());
assertEquals(expected.size(), actualJson.getArray("trackedEntityAttributes").size());
Expand All @@ -298,4 +311,18 @@ private static void assertAttributeList(JsonObject actualJson, Set<String> expec
.map(JsonString::string)
.collect(Collectors.toSet()));
}

private static void assertAttributePreferredOperator(
JsonObject actualJson, Set<String> expected) {
assertFalse(actualJson.isEmpty());
assertEquals(expected.size(), actualJson.getArray("trackedEntityAttributes").size());
assertEquals(
expected,
actualJson
.getArray("trackedEntityAttributes")
.projectAsList(e -> e.asObject().getString("preferredSearchOperator"))
.stream()
.map(JsonString::string)
.collect(Collectors.toSet()));
}
}
Loading