Use a projection to modify only one field but keeping default for all others

Viewed 88

First of all I'd like to say I love what i've seen so far from Spring Data JPA and Spring Data REST. Thanks a lot to all people involved.

Problem description

I have an entity model similar to the classes below. One parent and two different child entities referencing the parent als a ManyToOne Assoziation. For one of the childs i like to have the default rendering of all its properites and links as it is when no projection is applied to the parent.

The other child should be mapped to a simple string array containing only the id or some specific field.

Code and example JSONs

@Entity
public class Parent {
  
  @Id
  private Long id;
  
  private String parentValue;

  @OneToMany(mappedBy = "parent")
  private List<Child1> child1;
  
  @OneToMany(mappedBy = "parent")
  private List<Child2> child2;

  // ... getters and setters

}
@Entity
public class Child1 {

  @Id
  private Long id;
  
  private String child1Value;
  
  @ManyToOne
  Parent parent;

  // ... getters and setters
}
@Entity
public class Child2 {

  @Id
  private Long id;
  
  @ManyToOne
  Parent parent;
}

the response when getting the collection resource of parent is this

{
  "_embedded": {
    "parents": [
      {
        "parentValue": "Parent1",
        "child1": [
          {
            "child1Value": "Child1",
            "_links": {
              "parent": {
                "href": "http://localhost:8080/parents/1"
              }
            }
          }
        ],
        "child2": [
          {
            "_links": {
              "parent": {
                "href": "http://localhost:8080/parents/1"
              }
            }
          }
        ],
     // removed remaining json to shorten the example
}

But what i like to achieve is the following JSON

{
  "_embedded": {
    "parents": [
      {
        "parentValue": "Parent1",
        "child1": [
          {
            "child1Value": "Child1",
            "_links": {
              "parent": {
                "href": "http://localhost:8080/parents/1"
              }
            }
          }
        ],
        "child2": [1],

What i tried so far

Added an excerptProjection to the ParentRepository:

@RepositoryRestResource(excerptProjection = ParentRepository.ArrayProjection.class)
public interface ParentRepository extends PagingAndSortingRepository<Parent, Long>{

  public interface ArrayProjection {
    
    String getParentValue();
    List<Child1> getChild1();
    
    @Value("#{target.child2.![id]}")
    List<Long> getChild2();
    
  }
}

Edited: In the first version of the question, the Projection was incorrect regarding the return type of getChild1(), as it should return the complete collection not only one element. Thanks @kevvvvyp for still trying to help.

The result is similar to what i want, but the links on the Child1 property are missing now:

{
  "_embedded": {
    "parents": [
      {
        "child2": [
          2
        ],
        "child1": {
          "child1Value": "Child1"
        },
        "parentValue": "Parent1",

  // removed remaining json to shorten example

Also the approach with the excerptProjection means i'd have to change the projection everytime the entity changes. Which probalby won't happen to much but that means somebody will forget to change it in the future ;-)

1 Answers

I think a projection is the right way to go here, what about...

interface ArrayProjection {
    @JsonIgnore
    @Value("#{target}")
    Parent getParent();

    default String getParentValue(){
        return this.getParent().getParentValue();
    }

    default Child1 getChild1(){
        //TODO what are we actually trying to return here? Given we join the parent to
        // the child on id & id is the primary key on both entities can we ever get more than one child?
        return CollectionUtils.firstElement(this.getParent().getChild1());
    }

    default List<Long> getChild2() {
        return this.getParent().getChild2().stream()
                .map(Child2::getId)
                .collect(Collectors.toList());
    }

}

Response...

GET http://localhost:8080/api/parents

HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 31 Mar 2021 21:08:54 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "_embedded": {
    "parents": [
      {
        "parentValue": "Parent1",
        "child1": {
          "child1Value": "Child1"
        },
        "child2": [
          1
        ],
        "_links": {
          "self": {
            "href": "http://localhost:8080/api/parents/1"
          },
          "parent": {
            "href": "http://localhost:8080/api/parents/1{?projection}",
            "templated": true
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/api/parents"
    },
    "profile": {
      "href": "http://localhost:8080/api/profile/parents"
    }
  },
  "page": {
    "size": 20,
    "totalElements": 1,
    "totalPages": 1,
    "number": 0
  }
}

Response code: 200; Time: 713ms; Content length: 724 bytes

In reponse to your concern, if we code using default method's we will see a compile error when that entity changes. It might be possible to use a class projection instead.

Also I would consider if we really want to return a projection by default... it might confuse a client who then tries to create/update a parent (this is perhaps why you've kept the id's hidden by default?).

Sample - https://github.com/kevvvvyp/sdr-sample

Update

Another (more complex) way to acheive this could be to make use of Jackson's SimpleBeanPropertyFilter, you could customise the JSON response based on some value (the entity class, a value in a particular field, an annotation etc).

Related