Repository: dhis2/dhis2-core
Pull request #20808: chore: only map/fetch fields the user wanted DHIS2-19137
Author: login: teleivo
Created: 2025-04-28T10:27:46Z | Merged: 2025-04-30T11:38:57Z

Add `RelationshipFields` and `RelationshipItemFields` to avoid fetching relationship related data that is not going to be returned to the user as per their `fields` request parameter. This will affect

* `/tracker/trackedEntities`
* `/tracker/enrollments`
* `/tracker/events`
* `/tracker/relationships` - did not have its `fields` request parameter wired through into the service/store

This means that a request like `/trackedEntity?fields=relationships[relationship]` will not need to get the TE relationships `from` and `to` items with its nested entities.

There is one caveat which is the `RelationshipItem.trackedEntity/enrollment/event` which we currently always fetch. The `RelationshipService.findRelationshipItems` returns all items for a given entity. That item then has a relationship with a `from` and `to` `RelationshipItem` which is what we only fetch if needed. I can walk you through the 🤯 data model if you want to.

The difference to the other endpoints/entities is that relationships are fetched from the DB using hibernate. The reason is that hibernate resolves the cycle in our data model (due to relationships/relationship items) for us. The way we prevent unnecessary calls to the DB is to not map what is not needed in the `RelationshipItemMapper` in our service. Otherwise, the hibernate proxy of the `RelationshipItem` will call the DB.

## Change to query

Moved the Java `filter` into the query to not make hibernate proxy DB calls in a loop and allow the DB to optimize the query.

## Next

* try [fetching TEA in /trackedEntities only](https://github.com/dhis2/dhis2-core/blob/master/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/TrackedEntityAggregate.java#L154-L155) when the user wants them
* pass `Fields` class into event store instead of mapping it to new Java fields



Diff:
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/DefaultEnrollmentService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/DefaultEnrollmentService.java
index 88ec2e4881d8..0aa2a068fc94 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/DefaultEnrollmentService.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/DefaultEnrollmentService.java
@@ -221,7 +221,10 @@ private Enrollment getEnrollment(
     if (fields.isIncludesRelationships()) {
       result.setRelationshipItems(
           relationshipService.findRelationshipItems(
-              TrackerType.ENROLLMENT, UID.of(result), includeDeleted));
+              TrackerType.ENROLLMENT,
+              UID.of(result),
+              fields.getRelationshipFields(),
+              includeDeleted));
     }
     if (fields.isIncludesAttributes()) {
       result
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/EnrollmentFields.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/EnrollmentFields.java
index ce2c905e157d..abb1c637d1ed 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/EnrollmentFields.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/EnrollmentFields.java
@@ -35,6 +35,7 @@
 import lombok.Getter;
 import lombok.ToString;
 import org.hisp.dhis.tracker.export.event.EventFields;
+import org.hisp.dhis.tracker.export.relationship.RelationshipFields;
 
 /**
  * EnrollmentFields indicates which of the enrollment fields should be exported. This is used to
@@ -49,24 +50,40 @@
 @EqualsAndHashCode
 public class EnrollmentFields {
   private final boolean includesAttributes;
+
   private final boolean includesRelationships;
+  private final RelationshipFields relationshipFields;
 
   private final boolean includesEvents;
   private final EventFields eventFields;
 
   private EnrollmentFields(Builder builder) {
-    this.includesRelationships = builder.includesRelationships;
     this.includesAttributes = builder.includesAttributes;
+
+    this.includesRelationships = builder.includesRelationships;
+    this.relationshipFields =
+        builder.includesRelationships ? builder.relationshipFields : RelationshipFields.none();
+
     this.includesEvents = builder.includeEvents;
     this.eventFields = builder.includeEvents ? builder.eventFields : EventFields.none();
   }
 
   private EnrollmentFields(Predicate<String> includesFields, String pathSeparator) {
     this.includesAttributes = includesFields.test("attributes");
-    this.includesRelationships = includesFields.test("relationships");
+
+    if (includesFields.test("relationships")) {
+      this.includesRelationships = true;
+      this.relationshipFields =
+          RelationshipFields.of(
+              f -> includesFields.test("relationships" + pathSeparator + f), pathSeparator);
+    } else {
+      this.includesRelationships = false;
+      this.relationshipFields = RelationshipFields.none();
+    }
 
     if (includesFields.test("events")) {
-      this.eventFields = EventFields.of(f -> includesFields.test("events" + pathSeparator + f));
+      this.eventFields =
+          EventFields.of(f -> includesFields.test("events" + pathSeparator + f), pathSeparator);
       this.includesEvents = true;
     } else {
       this.eventFields = EventFields.none();
@@ -74,6 +91,14 @@ private EnrollmentFields(Predicate<String> includesFields, String pathSeparator)
     }
   }
 
+  /**
+   * Create fields class using the predicate to test if a given field should be exported. {@code
+   * pathSeparator} is used to concatenate paths for nested fields. The field filtering service
+   * which can be used as the predicate for example supports dot notation. That means you can use it
+   * to test if a nested path like {@code "events.relationships"} is included in the users {@code
+   * fields}. This allows us to compose fields classes in the same way as the structure the fields
+   * class is supposed to represent.
+   */
   public static EnrollmentFields of(
       @Nonnull Predicate<String> includesFields, @Nonnull String pathSeparator) {
     return new EnrollmentFields(includesFields, pathSeparator);
@@ -85,7 +110,7 @@ public static EnrollmentFields none() {
     return new EnrollmentFields(f -> false, "x");
   }
 
-  /** Use this if you do want fields to be exported. This is potentially expensive! */
+  /** Use this if you do want all fields to be exported. This is potentially expensive! */
   public static EnrollmentFields all() {
     // the path separator does not matter as the predicate returns true regardless of the path
     return new EnrollmentFields(f -> true, "x");
@@ -96,20 +121,25 @@ public static Builder builder() {
   }
 
   public static class Builder {
-    private boolean includesRelationships;
     private boolean includesAttributes;
+
+    private boolean includesRelationships;
+    private RelationshipFields relationshipFields;
+
     private boolean includeEvents;
     private EventFields eventFields;
 
     private Builder() {}
 
-    public Builder includeRelationships() {
-      this.includesRelationships = true;
+    public Builder includeAttributes() {
+      this.includesAttributes = true;
       return this;
     }
 
-    public Builder includeAttributes() {
-      this.includesAttributes = true;
+    /** Indicates that relationships should be exported with the given {@code fields}. */
+    public Builder includeRelationships(@Nonnull RelationshipFields fields) {
+      this.includesRelationships = true;
+      this.relationshipFields = fields;
       return 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 277a4060cf76..d16e204a84fd 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
@@ -244,7 +244,10 @@ public List<Event> findEvents(@Nonnull EventOperationParams operationParams)
       for (Event event : events) {
         event.setRelationshipItems(
             relationshipService.findRelationshipItems(
-                TrackerType.EVENT, UID.of(event), queryParams.isIncludeDeleted()));
+                TrackerType.EVENT,
+                UID.of(event),
+                operationParams.getFields().getRelationshipFields(),
+                queryParams.isIncludeDeleted()));
       }
     }
     return events;
@@ -261,7 +264,10 @@ public Page<Event> findEvents(
       for (Event event : events.getItems()) {
         event.setRelationshipItems(
             relationshipService.findRelationshipItems(
-                TrackerType.EVENT, UID.of(event), queryParams.isIncludeDeleted()));
+                TrackerType.EVENT,
+                UID.of(event),
+                operationParams.getFields().getRelationshipFields(),
+                queryParams.isIncludeDeleted()));
       }
     }
     return events;
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/EventFields.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/EventFields.java
index e51c9ea16090..54f114045818 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/EventFields.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/event/EventFields.java
@@ -34,9 +34,10 @@
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import lombok.ToString;
+import org.hisp.dhis.tracker.export.relationship.RelationshipFields;
 
 /**
- * EventFields indicates which of the tracked entity fields should be exported. This is used to save
+ * EventFields indicates which of the event fields should be exported. This is used to save
  * retrieval of data that is not needed. Be specific in what you need to save resources!
  */
 @Getter
@@ -44,27 +45,41 @@
 @EqualsAndHashCode
 public class EventFields {
   private final boolean includesRelationships;
+  private final RelationshipFields relationshipFields;
 
   private EventFields(Builder builder) {
     this.includesRelationships = builder.includesRelationships;
+    this.relationshipFields =
+        builder.includesRelationships ? builder.relationshipFields : RelationshipFields.none();
   }
 
-  private EventFields(Predicate<String> includesFields) {
-    this.includesRelationships = includesFields.test("relationships");
+  private EventFields(Predicate<String> includesFields, String pathSeparator) {
+    if (includesFields.test("relationships")) {
+      this.includesRelationships = true;
+      this.relationshipFields =
+          RelationshipFields.of(
+              f -> includesFields.test("relationships" + pathSeparator + f), pathSeparator);
+    } else {
+      this.includesRelationships = false;
+      this.relationshipFields = RelationshipFields.none();
+    }
   }
 
-  public static EventFields of(@Nonnull Predicate<String> includesFields) {
-    return new EventFields(includesFields);
+  public static EventFields of(
+      @Nonnull Predicate<String> includesFields, @Nonnull String pathSeparator) {
+    return new EventFields(includesFields, pathSeparator);
   }
 
   /** Use this if you do not want fields to be exported. */
   public static EventFields none() {
-    return new EventFields(f -> false);
+    // the path separator does not matter as the predicate returns false regardless of the path
+    return new EventFields(f -> false, "x");
   }
 
-  /** Use this if you do want fields to be exported. This is potentially expensive! */
+  /** Use this if you do want all fields to be exported. This is potentially expensive! */
   public static EventFields all() {
-    return new EventFields(f -> true);
+    // the path separator does not matter as the predicate returns true regardless of the path
+    return new EventFields(f -> true, "x");
   }
 
   public static Builder builder() {
@@ -73,11 +88,14 @@ public static Builder builder() {
 
   public static class Builder {
     private boolean includesRelationships;
+    private RelationshipFields relationshipFields;
 
     private Builder() {}
 
-    public Builder includeRelationships() {
+    /** Indicates that relationships should be exported with the given {@code fields}. */
+    public Builder includeRelationships(@Nonnull RelationshipFields fields) {
       this.includesRelationships = true;
+      this.relationshipFields = fields;
       return this;
     }
 
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/DefaultRelationshipService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/DefaultRelationshipService.java
index 7702033837fa..30a30993a152 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/DefaultRelationshipService.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/DefaultRelationshipService.java
@@ -63,11 +63,13 @@ public class DefaultRelationshipService implements RelationshipService {
   private final HibernateRelationshipStore relationshipStore;
   private final RelationshipOperationParamsMapper mapper;
 
-  // TODO(DHIS2-19137) Pass fields params as a parameter
   @Nonnull
   @Override
   public Set<RelationshipItem> findRelationshipItems(
-      TrackerType trackerType, UID uid, boolean includeDeleted) {
+      @Nonnull TrackerType trackerType,
+      @Nonnull UID uid,
+      @Nonnull RelationshipFields fields,
+      boolean includeDeleted) {
     List<RelationshipItem> relationshipItems =
         switch (trackerType) {
           case TRACKED_ENTITY ->
@@ -78,16 +80,12 @@ public Set<RelationshipItem> findRelationshipItems(
           case RELATIONSHIP -> throw new IllegalArgumentException("Unsupported type");
         };
     return relationshipItems.stream()
-        .filter(
-            ri ->
-                ri.getRelationship().getFrom().equals(ri)
-                    || ri.getRelationship().getRelationshipType().isBidirectional())
         .filter(
             ri ->
                 trackerAccessManager
                     .canRead(CurrentUserUtil.getCurrentUserDetails(), ri.getRelationship())
                     .isEmpty())
-        .map(RELATIONSHIP_ITEM_MAPPER::map)
+        .map(ri -> RELATIONSHIP_ITEM_MAPPER.map(fields, ri))
         .collect(Collectors.toSet());
   }
 
@@ -96,8 +94,7 @@ public Set<RelationshipItem> findRelationshipItems(
   public List<Relationship> findRelationships(@Nonnull RelationshipOperationParams params)
       throws ForbiddenException, NotFoundException, BadRequestException {
     RelationshipQueryParams queryParams = mapper.map(params);
-
-    return map(relationshipStore.getRelationships(queryParams));
+    return map(params.getFields(), relationshipStore.getRelationships(queryParams));
   }
 
   @Nonnull
@@ -107,14 +104,14 @@ public Page<Relationship> findRelationships(
       throws ForbiddenException, NotFoundException, BadRequestException {
     RelationshipQueryParams queryParams = mapper.map(params);
     Page<Relationship> relationships = relationshipStore.getRelationships(queryParams, pageParams);
-    return relationships.withFilteredItems(map(relationships.getItems()));
+    return relationships.withFilteredItems(map(params.getFields(), relationships.getItems()));
   }
 
   @Nonnull
   @Override
   public Optional<Relationship> findRelationship(@Nonnull UID uid) {
     try {
-      return Optional.of(getRelationship(uid));
+      return Optional.of(getRelationship(uid, RelationshipFields.none()));
     } catch (NotFoundException e) {
       return Optional.empty();
     }
@@ -122,12 +119,14 @@ public Optional<Relationship> findRelationship(@Nonnull UID uid) {
 
   @Nonnull
   @Override
-  public Relationship getRelationship(@Nonnull UID uid) throws NotFoundException {
+  public Relationship getRelationship(@Nonnull UID uid, @Nonnull RelationshipFields fields)
+      throws NotFoundException {
     Page<Relationship> relationships;
     try {
       relationships =
           findRelationships(
-              RelationshipOperationParams.builder(Set.of(uid)).build(), PageParams.single());
+              RelationshipOperationParams.builder(Set.of(uid)).fields(fields).build(),
+              PageParams.single());
     } catch (BadRequestException | ForbiddenException e) {
       throw new IllegalArgumentException(
           "this must be a bug in how the RelationshipOperationParams are built");
@@ -164,19 +163,19 @@ public List<Relationship> getRelationshipsByRelationshipKeys(
   }
 
   /** Map to a non-proxied Relationship to prevent hibernate exceptions. */
-  private List<Relationship> map(List<Relationship> relationships) {
+  private List<Relationship> map(RelationshipFields fields, List<Relationship> relationships) {
     List<Relationship> result = new ArrayList<>(relationships.size());
     for (Relationship relationship : relationships) {
       if (trackerAccessManager
           .canRead(CurrentUserUtil.getCurrentUserDetails(), relationship)
           .isEmpty()) {
-        result.add(map(relationship));
+        result.add(map(fields, relationship));
       }
     }
     return result;
   }
 
-  private Relationship map(Relationship relationship) {
+  private Relationship map(RelationshipFields fields, Relationship relationship) {
     Relationship result = new Relationship();
     result.setUid(relationship.getUid());
     result.setCreated(relationship.getCreated());
@@ -187,8 +186,12 @@ private Relationship map(Relationship relationship) {
     RelationshipType type = new RelationshipType();
     type.setUid(relationship.getRelationshipType().getUid());
     result.setRelationshipType(relationship.getRelationshipType());
-    result.setFrom(RELATIONSHIP_ITEM_MAPPER.map(relationship.getFrom()));
-    result.setTo(RELATIONSHIP_ITEM_MAPPER.map(relationship.getTo()));
+    result.setFrom(
+        RELATIONSHIP_ITEM_MAPPER.mapRelationshipItemWithoutRelationship(
+            fields.getFromFields(), relationship.getFrom()));
+    result.setTo(
+        RELATIONSHIP_ITEM_MAPPER.mapRelationshipItemWithoutRelationship(
+            fields.getToFields(), relationship.getTo()));
     result.setCreatedAtClient(relationship.getCreatedAtClient());
     return result;
   }
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/HibernateRelationshipStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/HibernateRelationshipStore.java
index 20ce39fac8c3..145315408fb6 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/HibernateRelationshipStore.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/HibernateRelationshipStore.java
@@ -142,7 +142,6 @@ public Optional<Event> findEvent(UID event, boolean includeDeleted) {
   }
 
   public List<Relationship> getRelationships(@Nonnull RelationshipQueryParams queryParams) {
-
     return relationshipsList(queryParams, null);
   }
 
@@ -175,11 +174,16 @@ public List<RelationshipItem> getRelationshipItemsByTrackedEntity(
     @Language("hql")
     String hql =
         """
-                from RelationshipItem ri
-                where ri.trackedEntity.uid = :trackedEntity
-                """;
+           select ri
+           from RelationshipItem ri
+           join ri.relationship r
+           join r.relationshipType rt
+           where
+            (r.from = ri or rt.bidirectional = true)
+            and ri.trackedEntity.uid = :trackedEntity
+        """;
     if (!includeDeleted) {
-      hql += "and ri.relationship.deleted = false";
+      hql += "and r.deleted = false";
     }
     return getQuery(hql, RelationshipItem.class)
         .setParameter("trackedEntity", trackedEntity.getValue())
@@ -191,11 +195,16 @@ public List<RelationshipItem> getRelationshipItemsByEnrollment(
     @Language("hql")
     String hql =
         """
-                from RelationshipItem ri
-                where ri.enrollment.uid = :enrollment
-                """;
+          select ri
+          from RelationshipItem ri
+          join ri.relationship r
+          join r.relationshipType rt
+          where
+           (r.from = ri or rt.bidirectional = true)
+           and ri.enrollment.uid = :enrollment
+        """;
     if (!includeDeleted) {
-      hql += "and ri.relationship.deleted = false";
+      hql += "and r.deleted = false";
     }
     return getQuery(hql, RelationshipItem.class)
         .setParameter("enrollment", enrollment.getValue())
@@ -206,11 +215,16 @@ public List<RelationshipItem> getRelationshipItemsByEvent(UID event, boolean inc
     @Language("hql")
     String hql =
         """
-                from RelationshipItem ri
-                where ri.event.uid = :event
-                """;
+          select ri
+          from RelationshipItem ri
+          join ri.relationship r
+          join r.relationshipType rt
+          where
+           (r.from = ri or rt.bidirectional = true)
+           and ri.event.uid = :event
+        """;
     if (!includeDeleted) {
-      hql += "and ri.relationship.deleted = false";
+      hql += "and r.deleted = false";
     }
     return getQuery(hql, RelationshipItem.class).setParameter("event", event.getValue()).list();
   }
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipFields.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipFields.java
new file mode 100644
index 000000000000..dda30e5ddff8
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipFields.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2004-2023, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors 
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.tracker.export.relationship;
+
+import java.util.function.Predicate;
+import javax.annotation.Nonnull;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+/**
+ * RelationshipFields indicates which of the relationship fields should be exported. This is used to
+ * save retrieval of data that is not needed. Be specific in what you need to save resources!
+ *
+ * <p>This class uses the field names of our view layer in {@link #RelationshipFields(Predicate,
+ * String)}. The field names currently match the field names of the entity classes in this module.
+ * We could map the view names in the predicate if we wanted to.
+ */
+@Getter
+@ToString
+@EqualsAndHashCode
+public class RelationshipFields {
+  private final boolean includesFrom;
+  private final RelationshipItemFields fromFields;
+
+  private final boolean includesTo;
+  private final RelationshipItemFields toFields;
+
+  /**
+   * Create fields class using the predicate to test if a given field should be exported. {@code
+   * pathSeparator} is used to concatenate paths for nested fields. The field filtering service
+   * which can be used as the predicate for example supports dot notation. That means you can use it
+   * to test if a nested path like {@code "from.trackedEntity.enrollments"} is included in the users
+   * {@code fields}. This allows us to compose fields classes in the same way as the structure the
+   * fields class is supposed to represent.
+   */
+  private RelationshipFields(Predicate<String> includesFields, String pathSeparator) {
+    if (includesFields.test("from")) {
+      this.includesFrom = true;
+      this.fromFields =
+          RelationshipItemFields.of(
+              f -> includesFields.test("from" + pathSeparator + f), pathSeparator);
+    } else {
+      this.includesFrom = false;
+      this.fromFields = RelationshipItemFields.none();
+    }
+
+    if (includesFields.test("to")) {
+      this.includesTo = true;
+      this.toFields =
+          RelationshipItemFields.of(
+              f -> includesFields.test("to" + pathSeparator + f), pathSeparator);
+    } else {
+      this.includesTo = false;
+      this.toFields = RelationshipItemFields.none();
+    }
+  }
+
+  public static RelationshipFields of(
+      @Nonnull Predicate<String> includesFields, @Nonnull String pathSeparator) {
+    return new RelationshipFields(includesFields, pathSeparator);
+  }
+
+  /** Use this if you do not want fields to be exported. */
+  public static RelationshipFields none() {
+    // the path separator does not matter as the predicate returns false regardless of the path
+    return new RelationshipFields(f -> false, "x");
+  }
+
+  /** Use this if you do want all fields to be exported. This is potentially expensive! */
+  public static RelationshipFields all() {
+    // the path separator does not matter as the predicate returns true regardless of the path
+    return new RelationshipFields(f -> true, "x");
+  }
+}
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipItemFields.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipItemFields.java
new file mode 100644
index 000000000000..00343eb98b4d
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipItemFields.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (c) 2004-2023, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors 
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.tracker.export.relationship;
+
+import java.util.function.Predicate;
+import javax.annotation.Nonnull;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+/**
+ * RelationshipItemFields indicates which of the relationship item fields should be exported. This
+ * is used to save retrieval of data that is not needed. Be specific in what you need to save
+ * resources!
+ *
+ * <p>This class uses the field names of our view layer in {@link #RelationshipItemFields(Predicate,
+ * String)}. The field names currently match the field names of the entity classes in this module.
+ * We could map the view names in the predicate if we wanted to.
+ *
+ * <p>This class uses the same approach for the fields classes we use in the view layer mappers
+ * which is duplicate the tracked entity, enrollment and event classes without the relationship
+ * fields to break the cycle inherent in the data model.
+ */
+@Getter
+@ToString
+@EqualsAndHashCode
+public class RelationshipItemFields {
+  private final boolean includesTrackedEntity;
+  private final TrackedEntityFields trackedEntityFields;
+
+  private final boolean includesEnrollment;
+  private final EnrollmentFields enrollmentFields;
+
+  private final boolean includesEvent;
+
+  private RelationshipItemFields(Predicate<String> includesFields, String pathSeparator) {
+    if (includesFields.test("trackedEntity")) {
+      this.includesTrackedEntity = true;
+      this.trackedEntityFields =
+          TrackedEntityFields.of(
+              f -> includesFields.test("trackedEntity" + pathSeparator + f), pathSeparator);
+    } else {
+      this.includesTrackedEntity = false;
+      this.trackedEntityFields = TrackedEntityFields.none();
+    }
+
+    if (includesFields.test("enrollment")) {
+      this.includesEnrollment = true;
+      this.enrollmentFields =
+          EnrollmentFields.of(f -> includesFields.test("enrollment" + pathSeparator + f));
+    } else {
+      this.includesEnrollment = false;
+      this.enrollmentFields = EnrollmentFields.none();
+    }
+
+    this.includesEvent = includesFields.test("event");
+  }
+
+  public static RelationshipItemFields of(
+      @Nonnull Predicate<String> includesFields, @Nonnull String pathSeparator) {
+    return new RelationshipItemFields(includesFields, pathSeparator);
+  }
+
+  /** Use this if you do not want fields to be exported. */
+  public static RelationshipItemFields none() {
+    // the path separator does not matter as the predicate returns false regardless of the path
+    return new RelationshipItemFields(f -> false, "x");
+  }
+
+  /** Use this if you do want all fields to be exported. This is potentially expensive! */
+  public static RelationshipItemFields all() {
+    // the path separator does not matter as the predicate returns true regardless of the path
+    return new RelationshipItemFields(f -> true, "x");
+  }
+
+  @Getter
+  @ToString
+  @EqualsAndHashCode
+  public static class TrackedEntityFields {
+    private final boolean includesAttributes;
+    private final boolean includesProgramOwners;
+
+    private final boolean includesEnrollments;
+    private final EnrollmentFields enrollmentFields;
+
+    private TrackedEntityFields(Predicate<String> includesFields, String pathSeparator) {
+      this.includesAttributes = includesFields.test("attributes");
+      this.includesProgramOwners = includesFields.test("programOwners");
+
+      if (includesFields.test("enrollments")) {
+        this.includesEnrollments = true;
+        this.enrollmentFields =
+            EnrollmentFields.of(f -> includesFields.test("enrollments" + pathSeparator + f));
+      } else {
+        this.includesEnrollments = false;
+        this.enrollmentFields = EnrollmentFields.none();
+      }
+    }
+
+    public static TrackedEntityFields of(
+        @Nonnull Predicate<String> includesFields, @Nonnull String pathSeparator) {
+      return new TrackedEntityFields(includesFields, pathSeparator);
+    }
+
+    public static TrackedEntityFields none() {
+      // the path separator does not matter as the predicate returns false regardless of the path
+      return new TrackedEntityFields(f -> false, "x");
+    }
+  }
+
+  @Getter
+  @ToString
+  @EqualsAndHashCode
+  public static class EnrollmentFields {
+    private final boolean includesTrackedEntity;
+    private final boolean includesAttributes;
+    private final boolean includesEvents;
+
+    private EnrollmentFields(Predicate<String> includesFields) {
+      this.includesTrackedEntity = includesFields.test("trackedEntity");
+      this.includesAttributes = includesFields.test("attributes");
+      this.includesEvents = includesFields.test("events");
+    }
+
+    public static EnrollmentFields of(@Nonnull Predicate<String> includesFields) {
+      return new EnrollmentFields(includesFields);
+    }
+
+    public static EnrollmentFields none() {
+      return new EnrollmentFields(f -> false);
+    }
+  }
+}
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipItemMapper.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipItemMapper.java
index b22026c10df4..4505330df0dc 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipItemMapper.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipItemMapper.java
@@ -51,6 +51,7 @@
 import org.hisp.dhis.tracker.imports.preheat.mappers.TrackedEntityTypeMapper;
 import org.hisp.dhis.user.User;
 import org.mapstruct.BeanMapping;
+import org.mapstruct.Context;
 import org.mapstruct.Mapper;
 import org.mapstruct.Mapping;
 import org.mapstruct.Named;
@@ -58,57 +59,108 @@
 @Mapper(
     uses = {ProgramStageMapper.class, RelationshipTypeMapper.class, TrackedEntityTypeMapper.class})
 public interface RelationshipItemMapper {
-  @BeanMapping(ignoreByDefault = true)
-  @Mapping(target = "relationship")
-  @Mapping(target = "trackedEntity")
-  @Mapping(target = "enrollment")
-  @Mapping(target = "event")
-  RelationshipItem map(RelationshipItem relationshipItem);
+  default RelationshipItem map(
+      @Context RelationshipFields fields, RelationshipItem relationshipItem) {
+    if (relationshipItem == null) {
+      return null;
+    }
 
-  @BeanMapping(ignoreByDefault = true)
-  @Mapping(target = "uid")
-  @Mapping(target = "relationshipType")
-  @Mapping(target = "created")
-  @Mapping(target = "createdAtClient")
-  @Mapping(target = "lastUpdated")
-  @Mapping(
-      target = "from",
-      source = "from",
-      qualifiedByName = "mapRelationshipItemWithoutRelationship")
-  @Mapping(target = "to", source = "to", qualifiedByName = "mapRelationshipItemWithoutRelationship")
-  Relationship map(Relationship relationship);
+    RelationshipItem result = new RelationshipItem();
+    result.setRelationship(map(fields, relationshipItem.getRelationship()));
+    // only one of these fields will be non null as it represents the entity in which the
+    // relationship originates. we might be able to optimize these mappings but
+    // that could result in more programmer errors as in unexpected NPEs
+    result.setTrackedEntity(
+        map(
+            RelationshipItemFields.all().getTrackedEntityFields(),
+            relationshipItem.getTrackedEntity()));
+    result.setEnrollment(
+        map(RelationshipItemFields.all().getEnrollmentFields(), relationshipItem.getEnrollment()));
+    result.setEvent(map(relationshipItem.getEvent()));
+    return result;
+  }
+
+  default Relationship map(@Context RelationshipFields fields, Relationship relationship) {
+    if (relationship == null) {
+      return null;
+    }
+
+    Relationship result = new Relationship();
+    result.setUid(relationship.getUid());
+    result.setRelationshipType(
+        RelationshipTypeMapper.INSTANCE.map(relationship.getRelationshipType()));
+    result.setCreated(relationship.getCreated());
+    result.setCreatedAtClient(relationship.getCreatedAtClient());
+    result.setLastUpdated(relationship.getLastUpdated());
+    result.setFrom(
+        mapRelationshipItemWithoutRelationship(fields.getFromFields(), relationship.getFrom()));
+    result.setTo(
+        mapRelationshipItemWithoutRelationship(fields.getToFields(), relationship.getTo()));
+    return result;
+  }
 
   // we need to ignore relationship to break the cycle between relationship and relationshipItem
   @Named("mapRelationshipItemWithoutRelationship")
-  @BeanMapping(ignoreByDefault = true)
-  @Mapping(target = "trackedEntity")
-  @Mapping(target = "enrollment")
-  @Mapping(target = "event")
-  RelationshipItem mapRelationshipItemWithoutRelationship(RelationshipItem relationshipItem);
+  default RelationshipItem mapRelationshipItemWithoutRelationship(
+      @Context RelationshipItemFields fields, RelationshipItem relationshipItem) {
+    if (relationshipItem == null) {
+      return null;
+    }
+
+    RelationshipItem result = new RelationshipItem();
+    if (fields.isIncludesTrackedEntity()) {
+      result.setTrackedEntity(
+          map(fields.getTrackedEntityFields(), relationshipItem.getTrackedEntity()));
+    }
+    if (fields.isIncludesEnrollment()) {
+      result.setEnrollment(map(fields.getEnrollmentFields(), relationshipItem.getEnrollment()));
+    }
+    if (fields.isIncludesEvent()) {
+      result.setEvent(map(relationshipItem.getEvent()));
+    }
+    return result;
+  }
 
   // these are needed to make mapstruct map these collections using the entity @Mappers
-  Set<Enrollment> mapEnrollments(Set<Enrollment> enrollments);
+  Set<Enrollment> mapEnrollments(
+      @Context RelationshipItemFields.EnrollmentFields fields, Set<Enrollment> enrollments);
 
   // these are needed to make mapstruct map these collections using the entity @Mappers
   Set<Event> mapEvents(Set<Event> events);
 
-  @BeanMapping(ignoreByDefault = true)
-  @Mapping(target = "uid")
-  @Mapping(target = "trackedEntityType")
-  @Mapping(target = "created")
-  @Mapping(target = "createdAtClient")
-  @Mapping(target = "lastUpdated")
-  @Mapping(target = "lastUpdatedAtClient")
-  @Mapping(target = "organisationUnit")
-  @Mapping(target = "createdByUserInfo")
-  @Mapping(target = "lastUpdatedByUserInfo")
-  @Mapping(target = "trackedEntityAttributeValues")
-  @Mapping(target = "inactive")
-  @Mapping(target = "deleted")
-  @Mapping(target = "potentialDuplicate")
-  @Mapping(target = "enrollments")
-  @Mapping(target = "programOwners")
-  TrackedEntity map(TrackedEntity trackedEntity);
+  default TrackedEntity map(
+      @Context RelationshipItemFields.TrackedEntityFields fields, TrackedEntity trackedEntity) {
+    if (trackedEntity == null) {
+      return null;
+    }
+
+    TrackedEntity result = new TrackedEntity();
+    result.setUid(trackedEntity.getUid());
+    result.setTrackedEntityType(
+        TrackedEntityTypeMapper.INSTANCE.map(trackedEntity.getTrackedEntityType()));
+    result.setCreated(trackedEntity.getCreated());
+    result.setCreatedAtClient(trackedEntity.getCreatedAtClient());
+    result.setLastUpdated(trackedEntity.getLastUpdated());
+    result.setLastUpdatedAtClient(trackedEntity.getLastUpdatedAtClient());
+    result.setOrganisationUnit(map(trackedEntity.getOrganisationUnit()));
+    result.setCreatedByUserInfo(trackedEntity.getCreatedByUserInfo());
+    result.setLastUpdatedByUserInfo(trackedEntity.getLastUpdatedByUserInfo());
+    result.setInactive(trackedEntity.isInactive());
+    result.setDeleted(trackedEntity.isDeleted());
+    result.setPotentialDuplicate(trackedEntity.isPotentialDuplicate());
+    if (fields.isIncludesAttributes()) {
+      result.setTrackedEntityAttributeValues(
+          mapTrackedEntityAttributeValues(trackedEntity.getTrackedEntityAttributeValues()));
+    }
+    if (fields.isIncludesProgramOwners()) {
+      result.setProgramOwners(mapTrackedEntityProgramOwners(trackedEntity.getProgramOwners()));
+    }
+    if (fields.isIncludesEnrollments()) {
+      result.setEnrollments(
+          mapEnrollments(fields.getEnrollmentFields(), trackedEntity.getEnrollments()));
+    }
+    return result;
+  }
 
   @BeanMapping(ignoreByDefault = true)
   @Mapping(target = "organisationUnit")
@@ -120,29 +172,43 @@ public interface RelationshipItemMapper {
   Set<TrackedEntityProgramOwner> mapTrackedEntityProgramOwners(
       Set<TrackedEntityProgramOwner> programOwners);
 
-  @BeanMapping(ignoreByDefault = true)
-  @Mapping(target = "uid")
-  @Mapping(target = "created")
-  @Mapping(target = "createdAtClient")
-  @Mapping(target = "lastUpdated")
-  @Mapping(target = "lastUpdatedAtClient")
-  @Mapping(target = "trackedEntity", qualifiedByName = "mapTrackedEntityForEnrollment")
-  @Mapping(target = "program")
-  @Mapping(target = "organisationUnit")
-  @Mapping(target = "enrollmentDate")
-  @Mapping(target = "occurredDate")
-  @Mapping(target = "followup")
-  @Mapping(target = "completedDate")
-  @Mapping(target = "createdByUserInfo")
-  @Mapping(target = "lastUpdatedByUserInfo")
-  @Mapping(target = "notes")
-  @Mapping(target = "events")
-  @Mapping(target = "status")
-  @Mapping(target = "deleted")
-  @Mapping(target = "geometry")
-  @Mapping(target = "storedBy")
-  Enrollment map(Enrollment enrollment);
+  default Enrollment map(
+      @Context RelationshipItemFields.EnrollmentFields fields, Enrollment enrollment) {
+    if (enrollment == null) {
+      return null;
+    }
+
+    Enrollment result = new Enrollment();
+    result.setUid(enrollment.getUid());
+    result.setCreated(enrollment.getCreated());
+    result.setCreatedAtClient(enrollment.getCreatedAtClient());
+    result.setLastUpdated(enrollment.getLastUpdated());
+    result.setLastUpdatedAtClient(enrollment.getLastUpdatedAtClient());
+    result.setProgram(map(enrollment.getProgram()));
+    result.setOrganisationUnit(map(enrollment.getOrganisationUnit()));
+    result.setEnrollmentDate(enrollment.getEnrollmentDate());
+    result.setOccurredDate(enrollment.getOccurredDate());
+    result.setFollowup(enrollment.getFollowup());
+    result.setCompletedDate(enrollment.getCompletedDate());
+    result.setCreatedByUserInfo(enrollment.getCreatedByUserInfo());
+    result.setLastUpdatedByUserInfo(enrollment.getLastUpdatedByUserInfo());
+    result.setNotes(mapNotes(enrollment.getNotes()));
+    result.setStatus(enrollment.getStatus());
+    result.setDeleted(enrollment.isDeleted());
+    result.setGeometry(enrollment.getGeometry());
+    result.setStoredBy(enrollment.getStoredBy());
+    if (fields.isIncludesTrackedEntity() || fields.isIncludesAttributes()) {
+      result.setTrackedEntity(mapTrackedEntityForEnrollment(enrollment.getTrackedEntity()));
+    }
+    if (fields.isIncludesEvents()) {
+      result.setEvents(mapEvents(enrollment.getEvents()));
+    }
+
+    return result;
+  }
 
+  // enrollment.trackedEntity is only exported as UID
+  // attribute values are needed to map enrollment.attributes as they are owned by the TE
   @Named("mapTrackedEntityForEnrollment")
   @BeanMapping(ignoreByDefault = true)
   @Mapping(target = "uid")
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipOperationParams.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipOperationParams.java
index 9c707cd4fce9..247fce136f31 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipOperationParams.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipOperationParams.java
@@ -49,6 +49,8 @@
 @AllArgsConstructor(access = AccessLevel.PRIVATE)
 @ToString
 public class RelationshipOperationParams {
+  private final RelationshipFields fields;
+
   private final TrackerType type;
 
   private final UID identifier;
@@ -88,11 +90,11 @@ public static RelationshipOperationParamsBuilder builder(
   }
 
   public static class RelationshipOperationParamsBuilder {
-
     private final List<Order> order = new ArrayList<>();
     private TrackerType type;
     private UID identifier;
     private Set<UID> relationships;
+    private RelationshipFields fields = RelationshipFields.none();
     private boolean includeDeleted;
 
     RelationshipOperationParamsBuilder() {}
@@ -122,9 +124,19 @@ public RelationshipOperationParamsBuilder includeDeleted(boolean includeDeleted)
       return this;
     }
 
+    public RelationshipOperationParamsBuilder fields(@Nonnull RelationshipFields fields) {
+      this.fields = fields;
+      return this;
+    }
+
     public RelationshipOperationParams build() {
       return new RelationshipOperationParams(
-          this.type, this.identifier, this.relationships, this.order, this.includeDeleted);
+          this.fields,
+          this.type,
+          this.identifier,
+          this.relationships,
+          this.order,
+          this.includeDeleted);
     }
   }
 
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipService.java
index 291e249c360e..f0173f4323fc 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipService.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipService.java
@@ -46,37 +46,47 @@
 
 public interface RelationshipService {
 
-  /** Find all relationship items matching given params. */
+  /**
+   * Find all relationship items matching given params while only fetching the minimum required
+   * {@code fields}.
+   */
   @Nonnull
   Set<RelationshipItem> findRelationshipItems(
-      TrackerType trackerType, UID uid, boolean includeDeleted);
+      @Nonnull TrackerType trackerType,
+      @Nonnull UID uid,
+      @Nonnull RelationshipFields fields,
+      boolean includeDeleted);
 
   /** Find all relationships matching given params. */
   @Nonnull
-  List<Relationship> findRelationships(RelationshipOperationParams params)
+  List<Relationship> findRelationships(@Nonnull RelationshipOperationParams params)
       throws ForbiddenException, NotFoundException, BadRequestException;
 
   /** Get a page of relationships matching given params. */
   @Nonnull
-  Page<Relationship> findRelationships(RelationshipOperationParams params, PageParams pageParams)
+  Page<Relationship> findRelationships(
+      @Nonnull RelationshipOperationParams params, @Nonnull PageParams pageParams)
       throws ForbiddenException, NotFoundException, BadRequestException;
 
   /**
    * Get a relationship matching given {@code UID} under the privileges of the currently
    * authenticated user. Returns an {@link Optional} indicating whether the relationship was found.
    *
+   * <p>This will not fetch {@link Relationship#getFrom()} and {@link Relationship#getTo()}.
+   *
    * @return an {@link Optional} containing the relationship if found, or an empty {@link Optional}
    *     if not
    */
   @Nonnull
-  Optional<Relationship> findRelationship(UID uid);
+  Optional<Relationship> findRelationship(@Nonnull UID uid);
 
   /**
    * Get a relationship matching given {@code UID} under the privileges of the currently
-   * authenticated user.
+   * authenticated user while only fetching the minimum required {@code fields}.
    */
   @Nonnull
-  Relationship getRelationship(UID uid) throws ForbiddenException, NotFoundException;
+  Relationship getRelationship(@Nonnull UID uid, @Nonnull RelationshipFields fields)
+      throws ForbiddenException, NotFoundException;
 
   /**
    * Find relationships matching given {@code UID}s under the privileges of the currently
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/DefaultTrackedEntityService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/DefaultTrackedEntityService.java
index 7c21e3290c10..3f396ea35ab8 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/DefaultTrackedEntityService.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/DefaultTrackedEntityService.java
@@ -254,7 +254,10 @@ private List<TrackedEntity> findTrackedEntities(
       if (operationParams.getFields().isIncludesRelationships()) {
         trackedEntity.setRelationshipItems(
             relationshipService.findRelationshipItems(
-                TrackerType.TRACKED_ENTITY, UID.of(trackedEntity), queryParams.isIncludeDeleted()));
+                TrackerType.TRACKED_ENTITY,
+                UID.of(trackedEntity),
+                operationParams.getFields().getRelationshipFields(),
+                queryParams.isIncludeDeleted()));
       }
     }
     for (TrackedEntity trackedEntity : trackedEntities) {
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityFields.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityFields.java
index 94d93df824dc..6cc3724f7d29 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityFields.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityFields.java
@@ -35,6 +35,7 @@
 import lombok.Getter;
 import lombok.ToString;
 import org.hisp.dhis.tracker.export.enrollment.EnrollmentFields;
+import org.hisp.dhis.tracker.export.relationship.RelationshipFields;
 
 /**
  * TrackedEntityFields indicates which of the tracked entity fields should be exported. This is used
@@ -49,16 +50,22 @@
 @EqualsAndHashCode
 public class TrackedEntityFields {
   private final boolean includesAttributes;
-  private final boolean includesRelationships;
   private final boolean includesProgramOwners;
 
+  private final boolean includesRelationships;
+  private final RelationshipFields relationshipFields;
+
   private final boolean includesEnrollments;
   private final EnrollmentFields enrollmentFields;
 
   private TrackedEntityFields(Builder builder) {
     this.includesAttributes = builder.includesAttributes;
-    this.includesRelationships = builder.includesRelationships;
     this.includesProgramOwners = builder.includesProgramOwners;
+
+    this.includesRelationships = builder.includesRelationships;
+    this.relationshipFields =
+        builder.includesRelationships ? builder.relationshipFields : RelationshipFields.none();
+
     this.includesEnrollments = builder.includesEnrollments;
     this.enrollmentFields =
         builder.includesEnrollments ? builder.enrollmentFields : EnrollmentFields.none();
@@ -66,9 +73,18 @@ private TrackedEntityFields(Builder builder) {
 
   private TrackedEntityFields(Predicate<String> includesFields, String pathSeparator) {
     this.includesAttributes = includesFields.test("attributes");
-    this.includesRelationships = includesFields.test("relationships");
     this.includesProgramOwners = includesFields.test("programOwners");
 
+    if (includesFields.test("relationships")) {
+      this.includesRelationships = true;
+      this.relationshipFields =
+          RelationshipFields.of(
+              f -> includesFields.test("relationships" + pathSeparator + f), pathSeparator);
+    } else {
+      this.includesRelationships = false;
+      this.relationshipFields = RelationshipFields.none();
+    }
+
     if (includesFields.test("enrollments")) {
       this.enrollmentFields =
           EnrollmentFields.of(
@@ -80,6 +96,14 @@ private TrackedEntityFields(Predicate<String> includesFields, String pathSeparat
     }
   }
 
+  /**
+   * Create fields class using the predicate to test if a given field should be exported. {@code
+   * pathSeparator} is used to concatenate paths for nested fields. The field filtering service
+   * which can be used as the predicate for example supports dot notation. That means you can use it
+   * to test if a nested path like {@code "enrollments.events"} is included in the users {@code
+   * fields}. This allows us to compose fields classes in the same way as the structure the fields
+   * class is supposed to represent.
+   */
   public static TrackedEntityFields of(
       @Nonnull Predicate<String> includesFields, @Nonnull String pathSeparator) {
     return new TrackedEntityFields(includesFields, pathSeparator);
@@ -91,7 +115,7 @@ public static TrackedEntityFields none() {
     return new TrackedEntityFields(f -> false, "x");
   }
 
-  /** Use this if you do want fields to be exported. This is potentially expensive! */
+  /** Use this if you do want all fields to be exported. This is potentially expensive! */
   public static TrackedEntityFields all() {
     // the path separator does not matter as the predicate returns true regardless of the path
     return new TrackedEntityFields(f -> true, "x");
@@ -102,19 +126,17 @@ public static Builder builder() {
   }
 
   public static class Builder {
-    private boolean includesRelationships;
     private boolean includesAttributes;
     private boolean includesProgramOwners;
+
+    private boolean includesRelationships;
+    private RelationshipFields relationshipFields;
+
     private boolean includesEnrollments;
     private EnrollmentFields enrollmentFields;
 
     private Builder() {}
 
-    public Builder includeRelationships() {
-      this.includesRelationships = true;
-      return this;
-    }
-
     public Builder includeAttributes() {
       this.includesAttributes = true;
       return this;
@@ -125,6 +147,13 @@ public Builder includeProgramOwners() {
       return this;
     }
 
+    /** Indicates that relationships should be exported with the given {@code fields}. */
+    public Builder includeRelationships(@Nonnull RelationshipFields fields) {
+      this.includesRelationships = true;
+      this.relationshipFields = fields;
+      return this;
+    }
+
     /** Indicates that enrollments should be exported with the given {@code fields}. */
     public Builder includeEnrollments(@Nonnull EnrollmentFields fields) {
       this.includesEnrollments = true;
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityFieldsTest.java b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityFieldsTest.java
index 7fe2fedac417..f2fb60166651 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityFieldsTest.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityFieldsTest.java
@@ -31,6 +31,8 @@
 
 import static org.junit.jupiter.api.Assertions.*;
 
+import org.hisp.dhis.tracker.export.relationship.RelationshipFields;
+import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 class TrackedEntityFieldsTest {
@@ -39,13 +41,22 @@ void shouldIncludeAllFields() {
     TrackedEntityFields fields = TrackedEntityFields.all();
 
     assertTrue(fields.isIncludesAttributes());
-    assertTrue(fields.isIncludesRelationships());
     assertTrue(fields.isIncludesProgramOwners());
+
+    assertTrue(fields.isIncludesRelationships());
+    assertRelationshipFields(fields.getRelationshipFields(), Assertions::assertTrue);
+
     assertTrue(fields.isIncludesEnrollments());
     assertTrue(fields.getEnrollmentFields().isIncludesAttributes());
     assertTrue(fields.getEnrollmentFields().isIncludesRelationships());
+    assertRelationshipFields(
+        fields.getEnrollmentFields().getRelationshipFields(), Assertions::assertTrue);
+
     assertTrue(fields.getEnrollmentFields().isIncludesEvents());
     assertTrue(fields.getEnrollmentFields().getEventFields().isIncludesRelationships());
+    assertRelationshipFields(
+        fields.getEnrollmentFields().getEventFields().getRelationshipFields(),
+        Assertions::assertTrue);
   }
 
   @Test
@@ -53,12 +64,71 @@ void shouldIncludeNoFields() {
     TrackedEntityFields fields = TrackedEntityFields.none();
 
     assertFalse(fields.isIncludesAttributes());
-    assertFalse(fields.isIncludesRelationships());
     assertFalse(fields.isIncludesProgramOwners());
+
+    assertFalse(fields.isIncludesRelationships());
+    assertRelationshipFields(fields.getRelationshipFields(), Assertions::assertFalse);
+
     assertFalse(fields.isIncludesEnrollments());
     assertFalse(fields.getEnrollmentFields().isIncludesAttributes());
     assertFalse(fields.getEnrollmentFields().isIncludesRelationships());
+    assertRelationshipFields(
+        fields.getEnrollmentFields().getRelationshipFields(), Assertions::assertFalse);
+
     assertFalse(fields.getEnrollmentFields().isIncludesEvents());
     assertFalse(fields.getEnrollmentFields().getEventFields().isIncludesRelationships());
+    assertRelationshipFields(
+        fields.getEnrollmentFields().getEventFields().getRelationshipFields(),
+        Assertions::assertFalse);
+  }
+
+  private static void assertRelationshipFields(
+      RelationshipFields relationshipFields, java.util.function.Consumer<Boolean> assertionMethod) {
+    assertionMethod.accept(relationshipFields.isIncludesFrom());
+    assertionMethod.accept(relationshipFields.getFromFields().isIncludesTrackedEntity());
+    assertionMethod.accept(
+        relationshipFields.getFromFields().getTrackedEntityFields().isIncludesAttributes());
+    assertionMethod.accept(
+        relationshipFields.getFromFields().getTrackedEntityFields().isIncludesProgramOwners());
+    assertionMethod.accept(
+        relationshipFields.getFromFields().getTrackedEntityFields().isIncludesEnrollments());
+    assertionMethod.accept(
+        relationshipFields
+            .getFromFields()
+            .getTrackedEntityFields()
+            .getEnrollmentFields()
+            .isIncludesAttributes());
+    assertionMethod.accept(
+        relationshipFields
+            .getFromFields()
+            .getTrackedEntityFields()
+            .getEnrollmentFields()
+            .isIncludesEvents());
+    assertionMethod.accept(relationshipFields.getFromFields().isIncludesEnrollment());
+    assertionMethod.accept(
+        relationshipFields.getFromFields().getEnrollmentFields().isIncludesAttributes());
+    assertionMethod.accept(
+        relationshipFields.getFromFields().getEnrollmentFields().isIncludesEvents());
+    assertionMethod.accept(relationshipFields.getFromFields().isIncludesEvent());
+    assertionMethod.accept(relationshipFields.isIncludesTo());
+    assertionMethod.accept(relationshipFields.getToFields().isIncludesTrackedEntity());
+    assertionMethod.accept(
+        relationshipFields
+            .getToFields()
+            .getTrackedEntityFields()
+            .getEnrollmentFields()
+            .isIncludesAttributes());
+    assertionMethod.accept(
+        relationshipFields
+            .getToFields()
+            .getTrackedEntityFields()
+            .getEnrollmentFields()
+            .isIncludesEvents());
+    assertionMethod.accept(relationshipFields.getToFields().isIncludesEnrollment());
+    assertionMethod.accept(
+        relationshipFields.getToFields().getEnrollmentFields().isIncludesAttributes());
+    assertionMethod.accept(
+        relationshipFields.getToFields().getEnrollmentFields().isIncludesEvents());
+    assertionMethod.accept(relationshipFields.getToFields().isIncludesEvent());
   }
 }
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/enrollment/EnrollmentServiceTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/enrollment/EnrollmentServiceTest.java
index 53d754097fad..d574fab034d6 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/enrollment/EnrollmentServiceTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/enrollment/EnrollmentServiceTest.java
@@ -77,6 +77,7 @@
 import org.hisp.dhis.trackedentity.TrackedEntityType;
 import org.hisp.dhis.trackedentityattributevalue.TrackedEntityAttributeValue;
 import org.hisp.dhis.tracker.export.event.EventFields;
+import org.hisp.dhis.tracker.export.relationship.RelationshipFields;
 import org.hisp.dhis.user.User;
 import org.hisp.dhis.user.UserService;
 import org.junit.jupiter.api.BeforeEach;
@@ -328,7 +329,8 @@ void shouldGetEnrollmentWithoutEventsWhenUserHasNoAccessToProgramStage()
 
   @Test
   void shouldGetEnrollmentWithRelationshipsWhenUserHasAccessToThem() throws NotFoundException {
-    EnrollmentFields fields = EnrollmentFields.builder().includeRelationships().build();
+    EnrollmentFields fields =
+        EnrollmentFields.builder().includeRelationships(RelationshipFields.all()).build();
 
     Enrollment enrollment = enrollmentService.getEnrollment(UID.of(enrollmentA), fields);
 
@@ -341,7 +343,8 @@ void shouldGetEnrollmentWithoutRelationshipsWhenUserHasAccessToThem() throws Not
     relationshipTypeA.getSharing().setOwner(admin);
     relationshipTypeA.getSharing().setPublicAccess(AccessStringHelper.DEFAULT);
 
-    EnrollmentFields fields = EnrollmentFields.builder().includeRelationships().build();
+    EnrollmentFields fields =
+        EnrollmentFields.builder().includeRelationships(RelationshipFields.all()).build();
 
     Enrollment enrollment = enrollmentService.getEnrollment(UID.of(enrollmentA), fields);
 
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityServiceTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityServiceTest.java
index 67609ef16397..43196049ff8f 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityServiceTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityServiceTest.java
@@ -108,6 +108,7 @@
 import org.hisp.dhis.tracker.PageParams;
 import org.hisp.dhis.tracker.acl.TrackedEntityProgramOwnerService;
 import org.hisp.dhis.tracker.export.enrollment.EnrollmentFields;
+import org.hisp.dhis.tracker.export.relationship.RelationshipFields;
 import org.hisp.dhis.tracker.export.trackedentity.TrackedEntityOperationParams.TrackedEntityOperationParamsBuilder;
 import org.hisp.dhis.tracker.trackedentityattributevalue.TrackedEntityAttributeValueService;
 import org.hisp.dhis.user.User;
@@ -1337,7 +1338,10 @@ void shouldReturnTrackedEntityWithoutEventsGivenTheyShouldNotBeIncluded()
     TrackedEntityFields fields =
         TrackedEntityFields.builder()
             .includeEnrollments(
-                EnrollmentFields.builder().includeAttributes().includeRelationships().build())
+                EnrollmentFields.builder()
+                    .includeAttributes()
+                    .includeRelationships(RelationshipFields.all())
+                    .build())
             .build();
     TrackedEntityOperationParams operationParams =
         TrackedEntityOperationParams.builder()
@@ -1519,7 +1523,8 @@ void shouldReturnEventMappedCorrectly()
   @Test
   void shouldReturnTrackedEntityWithRelationshipsTe2Te()
       throws ForbiddenException, NotFoundException, BadRequestException {
-    TrackedEntityFields fields = TrackedEntityFields.builder().includeRelationships().build();
+    TrackedEntityFields fields =
+        TrackedEntityFields.builder().includeRelationships(RelationshipFields.all()).build();
     TrackedEntityOperationParams operationParams =
         TrackedEntityOperationParams.builder()
             .organisationUnits(orgUnitA)
@@ -1671,7 +1676,8 @@ void shouldReturnTrackedEntityRelationshipWhenEventItemNotAccessible()
   @Test
   void returnTrackedEntityRelationshipsWithTe2Enrollment()
       throws ForbiddenException, NotFoundException, BadRequestException {
-    TrackedEntityFields fields = TrackedEntityFields.builder().includeRelationships().build();
+    TrackedEntityFields fields =
+        TrackedEntityFields.builder().includeRelationships(RelationshipFields.all()).build();
     TrackedEntityOperationParams operationParams =
         TrackedEntityOperationParams.builder()
             .organisationUnits(orgUnitA)
@@ -1699,7 +1705,7 @@ void shouldReturnTrackedEntityRelationshipsWithTe2Event()
       throws ForbiddenException, NotFoundException, BadRequestException {
     TrackedEntityFields fields =
         TrackedEntityFields.builder()
-            .includeRelationships()
+            .includeRelationships(RelationshipFields.all())
             .includeEnrollments(EnrollmentFields.all())
             .build();
     TrackedEntityOperationParams operationParams =
@@ -2391,7 +2397,7 @@ private TrackedEntityOperationParams createOperationParams(
         .trackedEntities(trackedEntity)
         .fields(
             TrackedEntityFields.builder()
-                .includeRelationships()
+                .includeRelationships(RelationshipFields.all())
                 .includeEnrollments(EnrollmentFields.all())
                 .build())
         .build();
diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/enrollment/EnrollmentsExportControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/enrollment/EnrollmentsExportControllerTest.java
index c41badb4c2cd..7852c8175999 100644
--- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/enrollment/EnrollmentsExportControllerTest.java
+++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/enrollment/EnrollmentsExportControllerTest.java
@@ -55,6 +55,7 @@
 import org.hisp.dhis.common.ValueType;
 import org.hisp.dhis.eventdatavalue.EventDataValue;
 import org.hisp.dhis.http.HttpStatus;
+import org.hisp.dhis.jsontree.JsonDiff.Mode;
 import org.hisp.dhis.jsontree.JsonList;
 import org.hisp.dhis.program.Enrollment;
 import org.hisp.dhis.program.Event;
@@ -134,7 +135,7 @@ void getEnrollmentByPathIsIdenticalToQueryParam() {
             .getList("enrollments", JsonEnrollment.class);
 
     assertHasSize(1, queryEnrollment.stream().toList());
-    assertNoDiff(pathEnrollment, queryEnrollment.get(0));
+    assertNoDiff(pathEnrollment, queryEnrollment.get(0), Mode.LENIENT);
   }
 
   @Test
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 8f0dd2cfe665..38cbcbf9c481 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
@@ -66,6 +66,7 @@
 import org.hisp.dhis.fileresource.FileResourceStorageStatus;
 import org.hisp.dhis.fileresource.ImageFileDimension;
 import org.hisp.dhis.http.HttpStatus;
+import org.hisp.dhis.jsontree.JsonDiff.Mode;
 import org.hisp.dhis.jsontree.JsonList;
 import org.hisp.dhis.jsontree.JsonObject;
 import org.hisp.dhis.note.Note;
@@ -194,7 +195,7 @@ void getEventByPathIsIdenticalToQueryParam() {
             .getList("events", JsonEvent.class);
 
     assertHasSize(1, queryEvents.stream().toList());
-    assertNoDiff(pathEvent, queryEvents.get(0));
+    assertNoDiff(pathEvent, queryEvents.get(0), Mode.LENIENT);
   }
 
   @Test
diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportControllerUnitTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportControllerUnitTest.java
index 40ddabcf7b5f..156ca690a570 100644
--- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportControllerUnitTest.java
+++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportControllerUnitTest.java
@@ -67,7 +67,7 @@ void shouldFailInstantiatingControllerIfAnyOrderableFieldIsUnsupported() {
     Exception exception =
         assertThrows(
             IllegalStateException.class,
-            () -> new RelationshipsExportController(relationshipService, paramsMapper, null));
+            () -> new RelationshipsExportController(relationshipService, paramsMapper, null, null));
 
     assertAll(
         () ->
diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntitiesExportControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntitiesExportControllerTest.java
index e00e2fcd0c57..2bb04c3e3a72 100644
--- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntitiesExportControllerTest.java
+++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntitiesExportControllerTest.java
@@ -37,7 +37,8 @@
 import static org.hisp.dhis.test.utils.Assertions.assertIsEmpty;
 import static org.hisp.dhis.test.utils.Assertions.assertNotEmpty;
 import static org.hisp.dhis.test.utils.Assertions.assertStartsWith;
-import static org.hisp.dhis.webapi.controller.tracker.Assertions.*;
+import static org.hisp.dhis.test.webapi.Assertions.assertNoDiff;
+import static org.hisp.dhis.webapi.controller.tracker.Assertions.assertNoErrors;
 import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertContainsAll;
 import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertHasMember;
 import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertHasNoMember;
@@ -62,6 +63,7 @@
 import org.hisp.dhis.fileresource.FileResourceService;
 import org.hisp.dhis.fileresource.FileResourceStorageStatus;
 import org.hisp.dhis.http.HttpStatus;
+import org.hisp.dhis.jsontree.JsonDiff.Mode;
 import org.hisp.dhis.jsontree.JsonList;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
 import org.hisp.dhis.program.Enrollment;
@@ -220,6 +222,26 @@ void shouldReturnEmptyListWhenGettingTrackedEntitiesWithNoMatchingParams() {
     assertEquals(0, trackedEntities.size());
   }
 
+  @Test
+  void getTrackedEntityByPathIsIdenticalToQueryParam() {
+    TrackedEntity trackedEntity = get(TrackedEntity.class, "QS6w44flWAf");
+
+    JsonTrackedEntity pathTrackedEntity =
+        GET("/tracker/trackedEntities/{id}?fields=*", trackedEntity.getUid())
+            .content(HttpStatus.OK)
+            .as(JsonTrackedEntity.class);
+    JsonList<JsonTrackedEntity> queryTrackedEntity =
+        GET(
+                "/tracker/trackedEntities?fields=*&trackedEntities={id}&trackedEntityType={type}",
+                trackedEntity.getUid(),
+                trackedEntity.getTrackedEntityType().getUid())
+            .content(HttpStatus.OK)
+            .getList("trackedEntities", JsonTrackedEntity.class);
+
+    assertHasSize(1, queryTrackedEntity.stream().toList());
+    assertNoDiff(pathTrackedEntity, queryTrackedEntity.get(0), Mode.LENIENT);
+  }
+
   @Test
   void getTrackedEntityById() {
     TrackedEntity te = get(TrackedEntity.class, "QS6w44flWAf");
diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/imports/TrackerCreateRelationshipSMSTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/imports/TrackerCreateRelationshipSMSTest.java
index 30d83fd974e9..3a8656e24de5 100644
--- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/imports/TrackerCreateRelationshipSMSTest.java
+++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/imports/TrackerCreateRelationshipSMSTest.java
@@ -71,6 +71,7 @@
 import org.hisp.dhis.test.webapi.json.domain.JsonWebMessage;
 import org.hisp.dhis.trackedentity.TrackedEntity;
 import org.hisp.dhis.trackedentity.TrackedEntityType;
+import org.hisp.dhis.tracker.export.relationship.RelationshipFields;
 import org.hisp.dhis.tracker.export.relationship.RelationshipService;
 import org.hisp.dhis.user.User;
 import org.hisp.dhis.user.sharing.UserAccess;
@@ -200,7 +201,9 @@ void shouldCreateRelationship() throws SmsCompressionException {
         () -> assertEquals(originator, sms.getOriginator()),
         () -> assertEquals(user, sms.getCreatedBy()),
         () -> {
-          Relationship relationship = relationshipService.getRelationship(UID.of(relationshipUid));
+          Relationship relationship =
+              relationshipService.getRelationship(
+                  UID.of(relationshipUid), RelationshipFields.all());
           assertAll(
               () -> assertEquals(relationshipUid, relationship.getUid()),
               () -> assertEquals(event1, relationship.getFrom().getEvent()),
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 1bc8eafbf63b..077eed734cf7 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
@@ -49,6 +49,7 @@
 import org.hisp.dhis.common.collection.CollectionUtils;
 import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.fieldfiltering.FieldFilterService;
+import org.hisp.dhis.fieldfiltering.FieldPath;
 import org.hisp.dhis.program.EnrollmentStatus;
 import org.hisp.dhis.tracker.TrackerIdSchemeParams;
 import org.hisp.dhis.tracker.export.event.EventFields;
@@ -144,7 +145,8 @@ public EventOperationParams map(
                 EventFields.of(
                     f ->
                         fieldFilterService.filterIncludes(
-                            Event.class, eventRequestParams.getFields(), f)))
+                            Event.class, eventRequestParams.getFields(), f),
+                    FieldPath.FIELD_PATH_SEPARATOR))
             .idSchemeParams(idSchemeParams);
 
     mapOrderParam(builder, eventRequestParams.getOrder());
diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventsExportController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventsExportController.java
index 81b6861628e3..d5c331220bbb 100644
--- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventsExportController.java
+++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/event/EventsExportController.java
@@ -285,7 +285,8 @@ ResponseEntity<ObjectNode> getEventByUid(
         EventFields.of(
             f ->
                 fieldFilterService.filterIncludes(
-                    org.hisp.dhis.webapi.controller.tracker.view.Event.class, fields, f));
+                    org.hisp.dhis.webapi.controller.tracker.view.Event.class, fields, f),
+            FieldPath.FIELD_PATH_SEPARATOR);
     MappingErrors errors = new MappingErrors(idSchemeParams);
     org.hisp.dhis.webapi.controller.tracker.view.Event event =
         EVENTS_MAPPER.map(
diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipRequestParamsMapper.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipRequestParamsMapper.java
index aefcda0daf79..6f8ab5c5e732 100644
--- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipRequestParamsMapper.java
+++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipRequestParamsMapper.java
@@ -43,9 +43,13 @@
 import org.hisp.dhis.common.OrderCriteria;
 import org.hisp.dhis.common.UID;
 import org.hisp.dhis.feedback.BadRequestException;
+import org.hisp.dhis.fieldfiltering.FieldFilterService;
+import org.hisp.dhis.fieldfiltering.FieldPath;
 import org.hisp.dhis.tracker.TrackerType;
+import org.hisp.dhis.tracker.export.relationship.RelationshipFields;
 import org.hisp.dhis.tracker.export.relationship.RelationshipOperationParams;
 import org.hisp.dhis.tracker.export.relationship.RelationshipOperationParams.RelationshipOperationParamsBuilder;
+import org.hisp.dhis.webapi.controller.tracker.view.Relationship;
 import org.springframework.stereotype.Component;
 
 /**
@@ -56,10 +60,11 @@
 @Component
 @RequiredArgsConstructor
 class RelationshipRequestParamsMapper {
-
   private static final Set<String> ORDERABLE_FIELD_NAMES =
       RelationshipMapper.ORDERABLE_FIELDS.keySet();
 
+  private final FieldFilterService fieldFilterService;
+
   public RelationshipOperationParams map(RelationshipRequestParams relationshipRequestParams)
       throws BadRequestException {
     UID trackedEntity = relationshipRequestParams.getTrackedEntity();
@@ -87,7 +92,15 @@ public RelationshipOperationParams map(RelationshipRequestParams relationshipReq
 
     mapOrderParam(builder, relationshipRequestParams.getOrder());
 
-    return builder.includeDeleted(relationshipRequestParams.isIncludeDeleted()).build();
+    return builder
+        .fields(
+            RelationshipFields.of(
+                f ->
+                    fieldFilterService.filterIncludes(
+                        Relationship.class, relationshipRequestParams.getFields(), f),
+                FieldPath.FIELD_PATH_SEPARATOR))
+        .includeDeleted(relationshipRequestParams.isIncludeDeleted())
+        .build();
   }
 
   private TrackerType getTrackerType(UID trackedEntity, UID enrollment, UID event)
diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportController.java
index 3dffb822458e..06288972811b 100644
--- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportController.java
+++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportController.java
@@ -43,8 +43,10 @@
 import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.feedback.ForbiddenException;
 import org.hisp.dhis.feedback.NotFoundException;
+import org.hisp.dhis.fieldfiltering.FieldFilterService;
 import org.hisp.dhis.fieldfiltering.FieldPath;
 import org.hisp.dhis.tracker.PageParams;
+import org.hisp.dhis.tracker.export.relationship.RelationshipFields;
 import org.hisp.dhis.tracker.export.relationship.RelationshipOperationParams;
 import org.hisp.dhis.tracker.export.relationship.RelationshipService;
 import org.hisp.dhis.webapi.controller.tracker.RequestHandler;
@@ -77,13 +79,17 @@ class RelationshipsExportController {
 
   private final RequestHandler requestHandler;
 
+  private final FieldFilterService fieldFilterService;
+
   public RelationshipsExportController(
       RelationshipService relationshipService,
       RelationshipRequestParamsMapper mapper,
-      RequestHandler requestHandler) {
+      RequestHandler requestHandler,
+      FieldFilterService fieldFilterService) {
     this.relationshipService = relationshipService;
     this.requestHandler = requestHandler;
     this.mapper = mapper;
+    this.fieldFilterService = fieldFilterService;
 
     assertUserOrderableFieldsAreSupported(
         "relationship",
@@ -133,7 +139,13 @@ ResponseEntity<ObjectNode> getRelationshipByUid(
       @OpenApi.Param(value = String[].class) @RequestParam(defaultValue = DEFAULT_FIELDS_PARAM)
           List<FieldPath> fields)
       throws NotFoundException, ForbiddenException {
-    Relationship relationship = RELATIONSHIP_MAPPER.map(relationshipService.getRelationship(uid));
+    RelationshipFields relationshipFields =
+        RelationshipFields.of(
+            f -> fieldFilterService.filterIncludes(Relationship.class, fields, f),
+            FieldPath.FIELD_PATH_SEPARATOR);
+
+    Relationship relationship =
+        RELATIONSHIP_MAPPER.map(relationshipService.getRelationship(uid, relationshipFields));
 
     return requestHandler.serve(relationship, fields);
   }
diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipRequestParamsMapperTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipRequestParamsMapperTest.java
index 53821f6d634f..87b23f8001d7 100644
--- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipRequestParamsMapperTest.java
+++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipRequestParamsMapperTest.java
@@ -38,19 +38,27 @@
 import static org.junit.jupiter.api.Assertions.assertAll;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
 
 import java.util.List;
 import org.hisp.dhis.common.OrderCriteria;
 import org.hisp.dhis.common.SortDirection;
 import org.hisp.dhis.common.UID;
 import org.hisp.dhis.feedback.BadRequestException;
+import org.hisp.dhis.fieldfiltering.FieldFilterService;
 import org.hisp.dhis.tracker.export.Order;
 import org.hisp.dhis.tracker.export.relationship.RelationshipOperationParams;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 class RelationshipRequestParamsMapperTest {
+  private RelationshipRequestParamsMapper mapper;
 
-  private final RelationshipRequestParamsMapper mapper = new RelationshipRequestParamsMapper();
+  @BeforeEach
+  void setUp() {
+    FieldFilterService fieldFilterService = mock(FieldFilterService.class);
+    mapper = new RelationshipRequestParamsMapper(fieldFilterService);
+  }
 
   @Test
   void getIdentifierParamThrowsIfNoParamsIsSet() {


Reviews:
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T12:40:16Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T14:45:54Z
user: login: enricocolasante
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T11:26:09Z
user: login: ameenhere
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T11:26:23Z

Review comments:
diff_hunk: @@ -49,16 +49,20 @@
   /** Find all relationship items matching given params. */
   @Nonnull
   Set<RelationshipItem> findRelationshipItems(
-      TrackerType trackerType, UID uid, boolean includeDeleted);
+      @Nonnull TrackerType trackerType,
+      @Nonnull UID uid,
+      @Nonnull RelationshipFields fields,
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/RelationshipService.java
user: login: github-advanced-security[bot]
body: ## Useless parameter

The parameter 'fields' is never used.

[Show more details](https://github.com/dhis2/dhis2-core/security/code-scanning/9688)
created_at: 2025-04-29T12:40:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -107,27 +104,29 @@
       throws ForbiddenException, NotFoundException, BadRequestException {
     RelationshipQueryParams queryParams = mapper.map(params);
     Page<Relationship> relationships = relationshipStore.getRelationships(queryParams, pageParams);
-    return relationships.withFilteredItems(map(relationships.getItems()));
+    return relationships.withFilteredItems(map(params.getFields(), relationships.getItems()));
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/relationship/DefaultRelationshipService.java
user: login: github-advanced-security[bot]
body: ## Deprecated method or constructor invocation

Invoking [Page.withFilteredItems](1) should be avoided because it has been deprecated.

[Show more details](https://github.com/dhis2/dhis2-core/security/code-scanning/9689)
created_at: 2025-04-29T14:45:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-30T08:08:42Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=dhis2_dhis2-core&pullRequest=20808) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=dhis2_dhis2-core&pullRequest=20808&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=dhis2_dhis2-core&pullRequest=20808&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=dhis2_dhis2-core&pullRequest=20808&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=dhis2_dhis2-core&pullRequest=20808&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=dhis2_dhis2-core&pullRequest=20808&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=dhis2_dhis2-core&pullRequest=20808)


author_association: NONE