如何让Spring Data投影接收关联关系的集合作为参数
问题描述
实体定义
public class RecipeDAO extends AbstractDAO { private Boolean visible; @ManyToMany(mappedBy = "recipes", cascade = CascadeType.ALL) private Set<TopicDAO> topics; @MapKey(name = "id.locale") @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true) private Map<String, Localization> localizations; @MapKey(name = "id.locale") @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true) private Map<String, JsonData> json; }
查询代码
你在Repository中定义的JPQL查询如下:
@Query("select new com.fullstack.dtos.RecipeDTO(t.id, t.createdBy, t.lastModifiedBy, t.created, t.lastModified, t.version, t.visible, l.title, l.description, l.footnote, topics) from recipes t inner join t.localizations l inner join t.topics topics where l.id.locale = :lang and l.title like %:title%") Page<RecipeDTO> findAllByTitle(String lang, String title, Pageable pageable);
问题表现
这是多语言系统,用Map存储多语言对应的localizations和json字段,不关联topics时查询正常,关联后出现两个核心问题:
- 构造投影时如果用
Collection/List/Set类型接收topics参数,会报类型不匹配,JPQL期望传入单个TopicDAO类型 - 用单个
TopicDAO接收时,数据库中仅1条Recipe数据,会返回和关联Topic数量一致的重复Recipe实例,所有实例@Id完全相同,且Hibernate会对每个Topic单独执行查询,出现N+1问题
你输出的日志如下:
TopicDAO(id=1771b663-e5d2-4a09-a758-9dec919cb3c6, topic=savory) TopicDAO(id=1de01b0d-b01c-42da-a5c5-78b2048b8f1a, topic=cheap) TopicDAO(id=342fdc0d-a091-4669-9753-69c6b9293562, topic=not vegan) TopicDAO(id=46ad594e-4aaf-4438-90fc-7401fca4fda4, topic=nice) TopicDAO(id=67477796-4a6d-4b48-9c69-c9692d341d7a, topic=easy) TopicDAO(id=be7a016d-7d6f-4cd1-a1ab-4faadf484c30, topic=nutritious) TopicDAO(id=c29347bd-e755-4414-9a63-28f70a2498cb, topic=meat) TopicDAO(id=d28cbaba-cd24-4256-8767-6cf9f75e5e1b, topic=lunch) TopicDAO(id=f845f510-15ad-471e-8cfb-6b46d0ab1ec6, topic=dinner) TopicDAO(id=fa97c9be-346b-435f-a8a1-4400c13f26cd, topic=breakfast) Page 1 of 1 containing com.fullstack.dtos.RecipeDTO instances [RecipeDTO(visible=true, topics=["easy"]), RecipeDTO(visible=true, topics=["breakfast"]), RecipeDTO(visible=true, topics=["nice"]), RecipeDTO(visible=true, topics=["lunch"]), RecipeDTO(visible=true, topics=["not vegan"]), RecipeDTO(visible=true, topics=["dinner"]), RecipeDTO(visible=true, topics=["cheap"]), RecipeDTO(visible=true, topics=["meat"]), RecipeDTO(visible=true, topics=["savory"]), RecipeDTO(visible=true, topics=["nutritious"])]
补充说明
AbstractDAO是所有DAO类的公共父类,AbstractDTO是所有DTO的公共父类,二者都存放@Id、@Created、@CreatedBy、@Version这类全局公共字段- 尝试过基于接口的投影,结果和类投影完全一致,仍返回重复的Recipe对象
解决方案
问题的核心原因是:普通inner join关联集合属性时,JPQL会展开集合生成笛卡尔积,每条关联的Topic对应一行结果,因此会返回重复的Recipe条目;同时标准JPQL的构造投影默认不支持直接传入集合类型参数,所以会报类型不匹配。
你可以选择以下任意一种方案解决:
方案1:使用Hibernate聚合函数直接返回集合(推荐,Hibernate 5.2+支持)
用Hibernate内置的collect()聚合函数把Topic收集为集合,配合GROUP BY合并相同Recipe的结果,修改后的查询如下:
@Query("select new com.fullstack.dtos.RecipeDTO(t.id, t.createdBy, t.lastModifiedBy, t.created, t.lastModified, t.version, t.visible, l.title, l.description, l.footnote, collect(topics)) " + "from RecipeDAO t " + "inner join t.localizations l " + "inner join t.topics topics " + "where l.id.locale = :lang and l.title like %:title% " + "group by t.id, t.createdBy, t.lastModifiedBy, t.created, t.lastModified, t.version, t.visible, l.title, l.description, l.footnote") Page<RecipeDTO> findAllByTitle(String lang, String title, Pageable pageable);
同时把RecipeDTO中topics的参数类型改为Set<TopicDAO>即可,不需要额外处理,直接返回合并后的集合。
方案2:使用fetch join + 实体转DTO
如果不想依赖Hibernate专属函数,可以先查询带关联数据的RecipeDAO实体,再手动转换为DTO:
@Query("select distinct t from RecipeDAO t " + "inner join fetch t.localizations l " + "inner join fetch t.topics " + "where l.id.locale = :lang and l.title like %:title%") Page<RecipeDAO> findAllByTitle(String lang, String title, Pageable pageable);
distinct会让Hibernate自动合并相同id的实体,避免重复,fetch关键字会一次性加载所有关联的Topic和Localization,解决N+1查询问题,拿到实体后手动映射为DTO即可。
方案3:结果后处理合并(兼容性最强)
如果不想修改原有查询逻辑,拿到查询返回的重复DTO列表后,按Recipe的id分组,把相同id的DTO的Topic合并为一个集合,再重新组装Page对象即可,适合需要兼容不同JPA实现的场景。
内容的提问来源于stack exchange,提问作者Goncalo Condeco
相关产品推荐
相关产品推荐

