1. 引言

本文将探讨如何在Hibernate中使用union操作整合两个相关数据库实体的查询结果。在Baeldung大学系统中,兼职讲师和访问研究员的信息存储在独立表中,但在内部报表或搜索场景下,需要将它们作为统一组查询。

我们将学习使用Hibernate 6原生支持的union功能,以及当直接支持不可行时手动实现类union行为的替代方案。

2. 场景与设置

使用两个简单的实体表示不同类型的人员。两个实体结构相似,仅有一个属性不同——这些共同属性是使用union语句的关键。

2.1 创建Researcher实体

先定义Researcher实体:

@Entity
public class Researcher {

    @Id
    private Long id;
    private String name;
    private boolean active;

    // 默认getter和setter
}

配套的Repository接口:

@Repository
public interface ResearcherRepository extends JpaRepository<Researcher, Long> {}

2.2 创建Lecturer实体

定义Lecturer类,区别在于包含facultyId字段:

@Entity
public class Lecturer {

    @Id
    private Long id;
    private String name;
    private Integer facultyId;

    // 默认getter和setter
}

其Repository接口:

@Repository
public interface LecturerRepository extends JpaRepository<Lecturer, Long> {}

2.3 创建DTO

使用DTO表示统一的人员对象。额外添加role属性作为标识列:

public class PersonDto {

    private Long id;
    private String name;
    private String role;

    // 默认getter、setter和构造方法
}

2.4 创建Service类

将各种实现方式集中到Service类中,便于测试:

@Service
public class UnionService {

    @PersistenceContext
    EntityManager em;

    @Autowired
    LecturerRepository lecturerRepository;

    @Autowired
    ResearcherRepository researcherRepository;

    @Autowired
    UnionService unionService;

    // ...
}

2.5 创建测试数据

最后准备六行测试数据(含一条重复记录以区分union和union all)。测试类如下:

@SpringBootTest
class UnionServiceIntegrationTest {

    @Autowired
    LecturerRepository lecturerRepository;

    @Autowired
    ResearcherRepository researcherRepository;

    @BeforeEach
    void setUp() {
        // ...
    }
}

创建测试数据:在讲师和研究员库中插入相同ID和姓名的人员:

lecturerRepository.saveAll(
  List.of( 
    new Lecturer(1l, "Alice"), new Lecturer(2l, "Bob"), new Lecturer(3l, "Candace") 
  )); 

researcherRepository.saveAll( 
  List.of( 
    new Researcher(3l, "Candace"), new Researcher(4l, "Diana"), new Researcher(5l, "Elena") 
));

3. 在JPQL查询中使用Union

从Hibernate 6开始,可直接通过createQuery()使用union语句。配合适当的构造函数,可在查询内使用构造表达式自动映射PersonDto结果。

在UnionService中添加实现方法:

public List<PersonDto> fetch() {
    return em.createQuery("""
      select new PersonDto(l.id, l.name) from Lecturer l
      union
      select new PersonDto(r.id, r.name) from Researcher r
      """, PersonDto.class)
      .getResultList();
}

测试验证:

@Test
void whenUnionQuery_thenUnifiedResult() {
    List<PersonDto> result = unionService.fetch();

    assertEquals(5, result.size());
}

由于union语句会去重,结果包含5条记录。若改用union all将返回6条。

4. 内存中模拟Union行为

可手动合并两个查询结果模拟union行为,并观察收集器类型对结果的影响。

4.1 合并到List

先通过继承JpaRepository的**findAll()**获取两个查询结果:

public List<PersonDto> fetchManually() {
    List<Lecturer> lecturers = lecturerRepository.findAll();
    List<Researcher> researchers = researcherRepository.findAll();

    // ...
}

使用Stream.concat()合并流,为role属性赋值。由于合并到List,结果与union all一致:

return Stream.concat( 
  lecturers.stream().map(l -> new PersonDto(l.getId(), l.getName(), "LECTURER")), 
  researchers.stream().map(r -> new PersonDto(r.getId(), r.getName(), "RESEARCHER"))) 
.toList();

⚠️ 缺点明显: 需执行两次查询导致性能下降,且分页困难。仅适合合并小表。

4.2 合并到Set

为去重可收集结果到Set

return Stream.concat(
  lecturers.stream().map(l -> new PersonDto(l.getId(), l.getName(), "LECTURER")),
  researchers.stream().map(r -> new PersonDto(r.getId(), r.getName(), "RESEARCHER")))
.collect(Collectors.toSet());

去重需在PersonDto中重写equals()和hashCode(): 仅检查实体共有的id和name字段,排除标识列:

public int hashCode() {
    return Objects.hash(id, name);
}

public boolean equals(Object obj) {
    if (this == obj)
        return true;
    
    if (obj == null || getClass() != obj.getClass())
        return false;

    PersonDto other = (PersonDto) obj;
    return Objects.equals(id, other.id) && Objects.equals(name, other.name);
}

5. 映射带Union的视图

当union查询过于复杂时,可在数据库中创建视图,仅在Java中映射。

5.1 创建视图

创建简单视图示例:

CREATE VIEW IF NOT EXISTS person_view AS 
SELECT id, name, 'LECTURER' AS role FROM Lecturer 
UNION 
SELECT id, name, 'RESEARCHER' AS role FROM Researcher

5.2 通过原生查询调用视图

在UnionService中新增方法,用**createNativeQuery()**直接映射结果到PersonDto:

public List<PersonDto> fetchView() {
    return em.createNativeQuery(
      "select e.id, e.name, e.role from person_view e", 
      PersonDto.class)
    .getResultList();
}

5.3 通过Repository查询调用视图

也可用**@Query注解创建投影接口。直接复用PersonDto会抛出ConverterNotFoundException!** 需新建接口:

public interface PersonView {

    Long getId();
    String getName();
    String getRole();
}

在LecturerRepository中添加查询方法,设置nativeQuery=true并返回PersonView:

@Query(value = "select e.id, e.name, e.role from person_view e", nativeQuery = true)
List<PersonView> findPersonView();

6. 使用CriteriaBuilder

Hibernate的CriteriaBuilder实现也支持union语句。

6.1 解包Hibernate Session

union语句非标准JPA API的一部分,需解包Session访问Hibernate实现:

public List<PersonDto> fetchWithCriteria() {
    var session = em.unwrap(Session.class);
    var builder = session.getCriteriaBuilder();

    // ...
}

6.2 生成子查询

分别创建子查询,先处理讲师查询。向**builder.createQuery()传入PersonDto,并构建Root**:

CriteriaQuery<PersonDto> lecturerQuery = builder.createQuery(PersonDto.class); 
Root<Lecturer> lecturer = lecturerQuery.from(Lecturer.class);

construct()方法构建基于PersonDto的SELECT子句。可用literal()方法添加根类型不存在的列:

lecturerQuery.select(builder.construct(
  PersonDto.class, lecturer.get("id"), lecturer.get("name"), builder.literal("LECTURER")));

研究员查询同理:

CriteriaQuery<PersonDto> researcherQuery = builder.createQuery(PersonDto.class); 
Root<Researcher> researcher = researcherQuery.from(Researcher.class); 

researcherQuery.select(builder.construct(
  PersonDto.class, researcher.get("id"), researcher.get("name"), builder.literal("RESEARCHER")));

6.3 合并结果

最终调用**unionAll()**并返回结果:

var unionQuery = builder.unionAll(lecturerQuery, researcherQuery); 
return session.createQuery(unionQuery).getResultList();

此方式虽繁琐,但在构建动态查询时特别有用——每一步都可完全控制。

7. 总结

本文以Baeldung大学系统为例,探讨了Hibernate中实现union查询的多种方式。Hibernate 6+在JPQL和Criteria API中均原生支持union,可高效整合不同实体的结果。

当原生支持不可行时,替代方案包括内存合并结果或映射数据库视图。各方案在性能、可维护性和分页支持上存在权衡。

完整源码可在GitHub获取。


原始标题:Implementing Unions in Hibernate | Baeldung