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!