spring-data-jpacriteria-apiblaze-persistence

Blaze Persistence returning duplicates - EntityViews, Spring Data JPA Specification and Pagination


I'm using the Blaze Persistence API with Spring Data JPA's specification API. When querying the database via EntityViewSpecificationExecutor with a Pageable object, duplicates are returned. How can I eliminate these duplicates?

In the below dummy scenario, I am trying to let my users filter course names by language, as well as other fields that I have removed from this question for brevity. A course can have multiple names in another language. For example, a course can have two Spanish names and three Greek names.

In my application, I have two entities:

@Data
@Entity
@Table(name = "courses")
public class Course implements Serializable {

   @Id
   @Column(name = "course_id")
   private Long id;

   @Column(name = "course_name")
   private String courseName; 

   @OneToMany(mappedBy = "anCourse", cascade = CascadeType.ALL)
    private List<AltName> altNameList;

   // Other fields and relationships removed for brevity
}
@Data
@Entity
@Table(name = "alt_names")
public class AltName implements Serializable {

   @Id
   @Column(name = "alt_id")
   private Long id;

   @Column(name = "alt_name")
   private String altName; 

   @Column(name = "alt_language")
   String altNameLanguage; // A course can have multiple alt names in the same language 

   @ManyToOne
   private Course anCourse;

   // Several other fields removed for brevity

And I have entity views:

EntityView(Course.class)
public interface SearchResultView extends CourseView {

    @Mapping(fetch = FetchStrategy.MULTISET)
    public List<AltNameView> getAltNameList();

    // Other multiset mappings removed for brevity
}
@EntityView(CourseView.class)
public interface CourseView {

    @IdMapping
    public Long getCourseId();

    public String getCourseName(); 
}
@EntityView(AltName.class)
public interface AltNameView {

    @IdMapping
    public int getId();

    public String getAltName();

    public String getAltNameLanguage();

    // Other fields removed for brevity
}

And this is how I'm attempting to query the database:

@Transactional(readOnly = true)
public interface CourseBlazeRepository extends EntityViewRepository<SearchResultView, Long>,
        EntityViewSpecificationExecutor<SearchResultView, Course> {

         Page<SearchResultView> findCourses(Specification<Course> courseSpecification, Pageable pageable);

}
// The below is expected to find zero, one, or more Spanish course names
 public static Specification<Course> filterForSpanishLanguage(){
        return ((root, criteriaQuery, criteriaBuilder) -> {
            Join<Course, AltName> altNames = root.join("altNameList", JoinType.INNER);

             return criteriaBuilder.equal(altNames.get(AltName_.ALT_NAME_LANGUAGE), "Spanish"); 
        });
    }

I'm using SpringBoot 3.3.3, Blaze persistence 1.6.12, and Hibernate 6.

As a workaround, I'm thinking I might have to use Blaze with the plain criteria API/follow demos from here, but I'd rather not have to do that if not necessary.

The scenario described above is a dummy scenario meant to reflect my actual application. Please disregard any oddities.

Note: If I use the "vanilla" JpaSpecificationExecutor from the Spring Data JPA library with the Pageable object, I am able to use criteriaQuery.distinct(true);, and the query returns the expected number of records. If I try to adjust the criteria query like this while using the EntityViewSpecificationExecutor, Spring throws the runtime exception "Cannot paginate a DISTINCT query" because the Blaze-generated query contains a distinct keyword.


Solution

  • The problem is that your specification will alter the cardinality of the result list because you are joining a *-to-many association. You should rather make use of an exists subquery predicate to essentially semi-join the altNameList like this:

        // The below is expected to find zero, one, or more Spanish course names
        public static Specification<Course> filterForSpanishLanguage(){
            return ((root, criteriaQuery, criteriaBuilder) -> {
                Subquery<Integer> subquery = criteriaQuery.subquery(Integer.class);
                subquery.select(cb.literal(1));
                Root<Course> correlatedRoot = subquery.correlate(root);
                Join<Course, AltName> altNames = correlatedRoot.join("altNameList", JoinType.INNER);
                subquery.where(criteriaBuilder.equal(altNames.get(AltName_.ALT_NAME_LANGUAGE), "Spanish"));
                return criteriaBuilder.exists(subquery); 
            });
        }