Hibernate左连接FETCH与ON子句或替代方案

3

我有一个父子关系。

父类具有以下字段(id,name),子类具有以下列(id,name,date,parent_id)。 我想返回的最终JSON如下。 即使父级没有子级,我也始终希望返回父级,这就是为什么我在子级上使用LEFT OUTER JOIN并使用ON子句的原因。

[

{
    "name": "parnet1",
    "child": {
      "2021-01-01": {
        "name": "child1"
      },
      "2021-01-02": {
        "name": "child2"
      }
    }
  },
  {
    "name": "parnet2",
    "child": {}
    }
  }
]

这是示例数据库的外观

parents

id    |     name
1     |     parent 1
2     |     parent 2

child

id    |     name    |    date    |    parent_id
1     |     child1  |  2021-01-01|    1
2     |     child2  |  2021-01-02|    1
3     |     child3  |  2020-12-31|    2

以下是一个示例,我传递的日期为2021年1月1日

因此,该月份为一月(1),年份为2021年

在父类中,我有一个用于引用子类的映射表

@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JsonManagedReference
@MapKey(name = "date")
private Map<LocalDate, Child> children;

以下是我的查询语句:

@Query("select p from Parent p left join p.child c on YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMonth

这样做的问题在于Hibernate会自动运行第二个查询,其语句如下:
select * from Child where c.parent_id = ?

这会获取所有子元素,无论是否符合连接条件。

然后我尝试了这个方法:

 @Query("select new Parent(p.id, p.name, c.id, c.date, c.name) from Parent p left join p.child c on YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMonth

并创建了这个构造器。
public Parent(int id, String name, int childId, LocalDate date, String childName) {

    this.id = id;
    this.name = name;
    this.children = new HashMap<LocalDate, Child>();
    if (childId != null) {
        Child child = new Child();
        child.setId(id);
        child.setName(name);
        this.children.put(date, child);
    }
}

但问题在于,当我想要数组的顶级长度为2时,我得到了一个长度为4的JSON数组,因为当前数据库中只有2个父项。
如何修改才能获得我想要的JSON有效载荷,这在问题的顶部发布。
非常感谢!
编辑
使用以下内容不起作用,因为child2未返回,如果我传入date = '2021-01-01'。
select distinct p from Parent p 
left join fetch p.children c 
where (YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMont) 
or c.parent is null

编辑 2(关于自动生成的查询)

我正在使用Spring Boot作为API运行,但没有额外的处理。以下是我的API和当前查询。

@GetMapping(path = "/parents/{date}", produces = { "application/json" })
@Operation(summary = "Get all parents for date")
public @ResponseBody List<Parent> getParentsByDate(@PathVariable("date") String dateString) {

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    LocalDate dateOccurred = LocalDate.parse(dateString, formatter);

    return parentRepository.getParentsForDate(dateOccurred);


}

然后在我的存储库中,我只有我的@Query语句,以下是我目前在ParentRepository类中拥有的内容。

public interface ParentRepository extends CrudRepository<Parent, Integer> {

    @Query("select p from Parent p left join fetch p.children c where (YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMonth) or c.parent is null")
    public List<Parent> getParentsForDate(@Param("dateOccurred") LocalDate dateOccurred
}

但是,问题在于如果父级在另一个月有任何子元素,但当前月份没有,那么null检查就无法正确工作,因此父级不会被返回。


你要在哪个字段上将它们连接起来?YEAR(c.date) = :dateYear 是什么意思?难道不应该是 @Query("select p from Parent p left join p.child c on c.parent_id = p.id WHERE YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMonth 吗? - MrFisherman
@MrFisherman 我也总是想返回父级,即使它没有子级,这就是为什么我在孩子上使用了LEFT OUTER JOIN和ON子句,而不是WHERE YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMonth。我已经更新了问题。 - iqueqiorio
@iqueqiorio 请问您能指出第二个查询select * from Child where c.parent_id = ?是何时生成的吗?我的意思是,您是否以某种方式处理实体?例如,将它们转换为JSON格式? - jccampanero
@jccampanero 第二个查询是在我的Query注释查询之后生成并运行的,但我没有在代码中调用它。我正在使用Spring Boot,并相信它正在生成它。我已经发布了第二次编辑。 - iqueqiorio
很高兴看到你找到了解决方案@iqueqiorio。很抱歉我无法帮助你解决问题。 - jccampanero
显示剩余5条评论
2个回答

7

解决方案:


我找到了一个获取和使用多个条件进行join操作的解决方案: 我们可以应用EntityGraph功能:

@NamedEntityGraph(name = "graph.Parent.children", attributeNodes = @NamedAttributeNode("children"))
public class Parent {
 ...
}

在存储库中,您需要像这样将此EntityGraph查询(Query)链接:

...
@Query("select distinct p from Parent p left join p.children c on YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMont")
@EntityGraph(value = "graph.Parent.children")
List<Parent> findAllParents(Integer dateYear, Integer dateMont);
...

先前的解决方案:


你知道,Hibernate不允许在on中使用多个条件进行fetch join,结果会引发with-clause not allowed on fetched associations; use filters异常,因此我建议使用以下HQL查询:

select distinct p from Parent p 
left join fetch p.children c 
where (YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMont) 
or c.parent is null
注意:distinct是为了避免父实体出现重复而必须使用的。有一篇文章详细介绍了此问题:https://thorben-janssen.com/hibernate-tips-apply-distinct-to-jpql-but-not-sql-query/ 因此,Hibernate 会生成一个查询,支持没有子项的父项。如果对于子项需要按照发布的 JSON 示例进行排序,则需要在children字段中添加@SortNatural
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JsonManagedReference
@MapKey(name = "date")
@SortNatural
private Map<LocalDate, Child> children;

结果,JSON将与所发布的内容相同。

建议:不要在运行时使用计算值,因为这些操作无法应用索引。对于您的情况,我建议使用虚拟列(如果是Mysql)或仅包含预先准备好的搜索值的单独索引列,并尝试通过索引连接表格,这些建议将加速查询执行。


感谢您的回复。问题在于,如果父级有任何子级,则查询中的“is null”部分不起作用,并且不会返回父级。 - iqueqiorio
@iqueqiorio 抱歉,我错过了筛选的情况,我已经更新了答案,请查看解决问题的新方法。 - saver
2
在Stackoverflow上有无数的帖子涉及到这个问题(如症状所示“不允许在获取的关联上使用with子句;请使用过滤器”),而你使用EntityGraph的解决方案是我能找到的唯一有效的方法。干得好! - Hein Blöd
同样的问题,感谢这个解决方案。 - sys463

0

你的原始查询:

@Query("select p from Parent p left join p.child c on YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMonth")

看起来很好,它将独立地返回所有的Parent实体,无论它们是否有children,正如您所需。

问题在于,之后,由于某种原因,可能是当您将信息序列化为JSON格式时,对于原始查询返回的每个parent,都会解析与childrenOneToMany关系。因此,执行了您提到的第二个查询,并且您得到的结果是不符合筛选条件的children

虽然我不清楚您的事务划分在哪里建立,但这可能是因为当实体被序列化时,它们仍然与持久性上下文相关联-相反,它会导致懒惰集合初始化的问题。

您可以尝试的一个选项是,在序列化它们之前将这些实体从持久性上下文中分离出来。

为了实现这个目标,首先在您的查询中包含fetch术语:

@Query("select p from Parent p left join fetch p.children c on YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMonth"

很遗憾,Spring Data没有提供一个开箱即用的解决方案来分离实体,但是您可以轻松地实现必要的代码。

首先,定义一个基本的存储库接口:

import java.io.Serializable;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.NoRepositoryBean;

@NoRepositoryBean
public interface CustomRepository<T, ID extends Serializable> extends JpaRepository<T, ID> {
  void detach(T entity);
}

以及它的实现:


import java.io.Serializable;

import javax.persistence.EntityManager;

import org.springframework.data.jpa.repository.support.JpaEntityInformation;
import org.springframework.data.jpa.repository.support.SimpleJpaRepository;

public class CustomRepositoryImpl<T, ID extends Serializable> extends SimpleJpaRepository<T, ID> implements CustomRepository<T, ID> {

  private final EntityManager entityManager;

  public CustomRepositoryImpl(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
    super(entityInformation, entityManager);
    this.entityManager = entityManager;
  }

  public CustomRepositoryImpl(Class<T> domainClass, EntityManager entityManager) {
    super(domainClass, entityManager);
    this.entityManager = entityManager;
  }

  public void detach(T entity) {
    this.entityManager.detach(entity);
  }

}

现在,为新的存储库创建一个工厂bean:
import java.io.Serializable;

import javax.persistence.EntityManager;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean;
import org.springframework.data.jpa.repository.support.SimpleJpaRepository;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.core.support.RepositoryFactorySupport;

public class CustomRepositoryFactoryBean<R extends JpaRepository<T, I>, T, I extends Serializable> extends JpaRepositoryFactoryBean<R, T, I> {

  public CustomRepositoryFactoryBean(Class<? extends R> repositoryInterface) {
    super(repositoryInterface);
  }

  @Override
  protected RepositoryFactorySupport createRepositoryFactory(EntityManager entityManager) {
    return new CustomRepositoryFactory(entityManager);
  }

  private static class CustomRepositoryFactory<T, I extends Serializable> extends JpaRepositoryFactory {

    private final EntityManager em;

    public CustomRepositoryFactory(EntityManager em) {

      super(em);
      this.em = em;
    }

    protected <T, ID extends Serializable> SimpleJpaRepository<?, ?> getTargetRepository(RepositoryMetadata metadata, EntityManager entityManager) {
      SimpleJpaRepository<?, ?> repo = new CustomRepositoryImpl(metadata.getDomainType(), entityManager);
      return repo;
    }

    protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
      return CustomRepositoryImpl.class;
    }
  }

}

这个工厂必须在你的@EnableJpaRepositories注解中注册:

@EnableJpaRepositories(repositoryFactoryBeanClass = CustomRepositoryFactoryBean.class /* your other configuration */)

你的代码库需要实现新定义的内容:

public interface ParentRepository extends CustomRepository<Parent, Integer> {

    @Query("select p from Parent p left join fetch p.children c where (YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMonth) or c.parent is null")
    @Transactional(readOnly=true)
    public List<Parent> getParentsForDate(@Param("dateOccurred") LocalDate dateOccurred)
}

创建一个新的服务:
public interface ParentService {
  List<Parent> getParentsForDate(LocalDate dateOccurred);
}

对应的实现如下:

@Service
public class ParentServiceImpl implements ParentService {
  private final ParentRepository parentRepository;

  public ParentServiceImpl(ParentRepository parentRepository) {
    this.parentRepository = parentRepository;
  }

  @Override
  @Transactional
  public List<Parent> getParentsForDate(LocalDate dateOccurred) {
    Lis<Parent> parents = parentRepository.getParentsForDate(dateOccurred);

    // Now, the tricky part... I must recognize that I never tried
    // something like this, but I think it should work
    parents.forEach(parent -> parentRepository.detach(parent));
    return parents;
  }
}

在控制器中使用该服务:

@GetMapping(path = "/parents/{date}", produces = { "application/json" })
@Operation(summary = "Get all parents for date")
public @ResponseBody List<Parent> getParentsByDate(@PathVariable("date") String dateString) {

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    LocalDate dateOccurred = LocalDate.parse(dateString, formatter);

    return parentService.getParentsForDate(dateOccurred);

}

我还没有测试过这段代码,也不知道这种分离实体的方式是否可行,但我认为很可能会成功。

如果这种方法不起作用,我的建议是尝试另一种方法。

与其查询父级,不如直接过滤子级并获取符合筛选条件的子级。

@Query("select c from Child c on YEAR(c.date) = :dateYear and MONTH(c.date) = :dateMonth)

另一方面,列出所有的Parent(例如使用findAll),并创建某种中间POJO,即DTO,以混合获取的几个结果的信息,并将该信息返回给客户端:
public class ParentDTO {
  private long id;
  private String name;
  private Map<LocalDate, ChildDTO> children = Collections.emptyMap();

  // Setters and getters
}

public class ChildDTO {
  private long id;
  private String name;
  private LocalDate date;

  // Setters and getters
}

  List<Child> children = /* the aforementioned query conforming to filter criteria */
  List<Parent> parents = parentRepository.findAll();

  List<ParentDTO> parentDTOs = new ArrayList(parents.size());
  for (Parent parent:parents) {
    ParentDTO parentDTO = new ParentDTO();
    parentDTOs.add(parentDTO);

    parentDTO.setId(parent.getId());
    parentDTO.setName(parent.getName());

    // Contains parent?
    if (children == null || children.isEmpty()) {
      continue;
    }

    Map<LocalDate, ChildDTO> childrenForThisParent = children.stream()
      .filter(child -> child.parent.equals(parent))
      .map(child -> {
        ChildDTO childDTO = new ChildDTO();
        childDTO.setId(child.getId());
        childDTO.setName(child.getName());
        childDTO.setDate(child.getDate());
        return childDTO;
      })
      .collect(Collectors.toMap(c -> c.getDate(), c));

    parentTO.setChildren(childrenForThisParent);
  }

这段代码有几种优化方式,但我希望你能理解我的意思。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接