Home > front end >  How to ignore field from Spring Data JPA projection mapping
How to ignore field from Spring Data JPA projection mapping

Time:09-30

I'm building a REST API for a Q&A web application. It has communities where each community is defined by an id, name, description, members, and others. Right now, I have an endpoint for fetching the given community data /api/v1/communities/{id}.

The response looks like this

 {
    "name": "VIP",
    "id": 5,
    "displayName": "VIP",
    "subtopics": [
        "Sports"
    ],
    "primaryTopic": "Gaming",
    "about": "",
    "isPublic": false,
    "isPrivate": true,
    "isRestricted": false
}

The response is a DTO that's mapped automatically using Spring Data Projection. Now, I want to add a new field to the DTO. The field called isMember and it should store whether the currently authenticated user is a member of the given community.

public interface CommunityResponse {
    Long getId();

    String getName();

    String getDisplayName();

    String getAbout();

    @Value("#{target.primaryTopic.getDisplayName()}")
    String getPrimaryTopic();

    @Value("#{target.getSubtopicsDisplayNames()}")
    Set<String> getSubtopics();

    @Value("#{target.isPublic()}")
    @JsonProperty("isPublic")
    boolean isPublic();

    @Value("#{target.isPrivate()}")
    @JsonProperty("isPrivate")
    boolean isPrivate();

    @Value("#{target.isRestricted()}")
    @JsonProperty("isRestricted")
    boolean isRestricted();
   
    // boolean isMember();

}

I thought about using Class-based projection. I converted the interface into a class and added the isMember field to the DTO and called setIsMember() inside the service class, but I got an error that says no property 'isMember' found for type 'CommunityResponse'

As far as I understand, each field in a projection class or interface must either share the same name as a field in the entity or it should be derived from other fields using Spring’s expression language.

The problem is that isMember is not a field from the entity and I don't know how to derive it using Spring’s expression language. The logic for initializing isMember looks like this:

       public boolean isMember(Long communityId, Authentication authentication) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return userAccountRepository.findByEmail(userDetails.getUsername()).map(user -> {
            return user.getJoinedCommunities().stream().anyMatch(community -> community.getId() == communityId);
        }).orElse(false);
    }

Other classes

UserAccount.java

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Data
public class UserAccount {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    @NotBlank(message = "Firstname is required")
    private String firstname;
    @NotBlank(message = "Lastname is required")
    private String lastname;
    @NotBlank(message = "Username is required")
    private String username;
    @NotBlank(message = "Email is required")
    private String email;
    @NotBlank(message = "Password is required")
    private String hashedPassword;
    private Instant creationDate;
    private Boolean activated;
    @ManyToMany(fetch = FetchType.LAZY, mappedBy = "members")
    private Set<Community> joinedCommunities;
}

Community.java

@Entity
@Table(name = "community")
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Community {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Enumerated(EnumType.STRING)
    private CommunityType type;

    @Column(unique = true)
    private String name;
    private String displayName;

    private String about;

    @Enumerated(EnumType.STRING)
    private Topic primaryTopic;

    @CollectionTable(name = "subtopic")
    @ElementCollection(targetClass = Topic.class)
    @Enumerated(EnumType.STRING)
    @Column(name = "topic")
    private Set<Topic> subtopicSet;

    @ManyToMany
    @JoinTable(name = "community_members",
            joinColumns = { @JoinColumn(name = "community_id") },
            inverseJoinColumns = { @JoinColumn(name = "member_id") })
    private Set<UserAccount> members;


    public String getDisplayName() {
        return displayName;
    }

    public boolean isPublic() {
        return type == CommunityType.PUBLIC;
    }

    public boolean isPrivate() {
        return type == CommunityType.PRIVATE;
    }

    public boolean isRestricted() {
        return type == CommunityType.RESTRICTED;
    }

    public Set<String> getSubtopicsDisplayNames() {
        return getSubtopicSet().stream().map(Topic::getDisplayName).collect(Collectors.toSet());
    }

}

CommunityRepository.java

public interface CommunityRepository extends JpaRepository<Community, Long> {
    Optional<CommunityResponse> findCommunityById(Long id);

    List<CommunityResponse> findCommunityResponseBy();
}

CodePudding user response:

I thought about using Class-based projection. I converted the interface into a class and added the isMember field to the DTO

You could try to derive isMember within JPQL query constructing class-based projection:

@Query("select new com.my.project.DTO(c.id, c.name, (:user member of c.members)) from Community c ...")
List<CommunityResponse> findCommunityResponseBy(UserAccount user);

Alternatively, in interface-based projection you could try

@Value("#{target.members.contains(#user)}")
boolean isMember;

where #user is the argument of findBy method.

CodePudding user response:

Spring Data Projections is very limited and might run into N 1 issues when doing stuff like this. So using a dedicated DTO query would be advisable. I think though that this is a perfect use case for Blaze-Persistence Entity Views.

I created the library to allow easy mapping between JPA models and custom interface or abstract class defined models, something like Spring Data Projections on steroids. The idea is that you define your target structure(domain model) the way you like and map attributes(getters) via JPQL expressions to the entity model.

A DTO model for your use case could look like the following with Blaze-Persistence Entity-Views:

@EntityView(Community.class)
public interface CommunityResponse {
    @IdMapping
    Long getId();
    String getName();

    String getDisplayName();

    String getAbout();

    @JsonIgnore
    @Mapping("primaryTopic")
    Topic getPrimaryTopicEnum();

    @JsonIgnore
    Set<Topic> getSubtopicSet();

    default String getPrimaryTopic() {
        return getPrimaryTopicEnum().getDisplayName();
    }

    default Set<String> getSubtopics() {
        return getSubtopicSet().stream().map(Topic::getDisplayName).collect(toSet());
    }

    @JsonIgnore
    CommunityType getType();

    @JsonProperty("isPublic")
    default boolean isPublic() {
        return getType() == CommunityType.PUBLIC;
    }

    @JsonProperty("isPrivate")
    default boolean isPrivate() {
        return getType()== CommunityType.PRIVATE;
    }

    @JsonProperty("isRestricted")
    default boolean isRestricted() {
        return getType()== CommunityType.RESTRICTED;
    }

    @Mapping("case when this member of User[id = :currentUserId].joinedCommunities then true else false end")
    boolean isMember();

}

Querying is a matter of applying the entity view to a query, the simplest being just a query by id.

CommunityResponse a = entityViewManager.find(entityManager, CommunityResponse.class, id);

The Spring Data integration allows you to use it almost like Spring Data Projections: https://persistence.blazebit.com/documentation/entity-view/manual/en_US/index.html#spring-data-features

Page<CommunityResponse> findAll(Pageable pageable);

The best part is, it will only fetch the state that is actually necessary!

  • Related