1. 概述

在实际开发中,我们经常会遇到数据库表之间没有外键约束,但业务上存在逻辑关联的情况。本文将探讨如何使用 JPA(Java Persistence API)在无直接关系的实体之间构造查询,并通过 JPQL 和 QueryDSL 两种方式实现。

这类场景在遗留系统或微服务拆分后的数据库中尤为常见——表之间靠约定的字段名匹配,而非严格的外键约束。处理不当容易踩坑,比如 N+1 查询、笛卡尔积等。


2. Maven 依赖

首先引入必要的依赖。核心是 Hibernate 6(JPA 实现),并搭配 QueryDSL 提供类型安全的查询能力。

注意:使用 Jakarta EE 9+ 命名空间(jakarta.persistence),不再是 javax.persistence

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>6.2.0.Final</version>
</dependency>

QueryDSL 支持编译时生成元模型类(如 QCocktail),避免字符串拼接:

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <classifier>jakarta</classifier>
    <version>5.0.0</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
    <version>5.0.0</version>
</dependency>

为避免运行时缺少 JAXB 类(Hibernate 有时需要),显式引入:

<dependency>
    <groupId>jakarta.xml.bind</groupId>
    <artifactId>jakarta.xml.bind-api</artifactId>
    <version>4.0.0</version>
</dependency>

⚠️ 若使用 Spring Boot,建议使用 spring-boot-starter-data-jpa 并确认其包含 Jakarta 版本。


3. 领域模型

我们以一个鸡尾酒吧管理系统为例,数据库中有两张表:

  • menu:存储鸡尾酒名称和价格
  • recipes:存储调酒配方说明

这两张表没有外键约束,但通过 cocktail_namecocktail 字段隐式关联。例如:

  • 菜单中有 "Mojito",配方表也有 "Mojito" 记录 → 存在逻辑关联
  • Gin Tonic 在菜单中,但配方表无记录 → 无配方
  • 某配方存在但菜单未上架 → 未启用

目标:查询菜单中已有配方的鸡尾酒


4. JPA 实体定义

先定义基础实体:

@Entity
@Table(name = "menu")
public class Cocktail {
    @Id
    @Column(name = "cocktail_name")
    private String name;

    @Column
    private double price;

    // getters & setters
}
@Entity
@Table(name="recipes")
public class Recipe {
    @Id
    @Column(name = "cocktail")
    private String cocktail;

    @Column
    private String instructions;
    
    // getters & setters
}

建立逻辑关联

虽然无外键,但我们可以通过 @OneToOne + @JoinColumn 构造逻辑上的一对一关系

@Entity
@Table(name = "menu")
public class Cocktail {
    // ...

    @OneToOne
    @JoinColumn(
        name = "cocktail_name",           // 当前表字段
        referencedColumnName = "cocktail", // 关联表字段
        insertable = false,               // 禁止插入时操作该字段
        updatable = false,                // 禁止更新时操作该字段
        foreignKey = @jakarta.persistence.ForeignKey(value = ConstraintMode.NO_CONSTRAINT)
    )
    private Recipe recipe;
   
    // ...
}

关键点解释:

  • @OneToOne:声明一对一关系
  • @JoinColumn:指定连接字段,建立“伪外键”
  • insertable = false, updatable = false:防止 ORM 误操作关联字段
  • ConstraintMode.NO_CONSTRAINT禁止生成外键约束,避免 DDL 冲突
  • ⚠️ 若关联记录不存在,查询时默认会报错。可加 @NotFound(action = NotFoundAction.IGNORE)(Hibernate 特有)忽略缺失

5. JPA 与 QueryDSL 查询方式

目标:查询所有有配方的鸡尾酒

方式一:通过实体关系字段连接(推荐)

利用 Cocktail.recipe 字段进行连接。

JPQL:

entityManager.createQuery("select c from Cocktail c join c.recipe", Cocktail.class)

QueryDSL:

new JPAQuery<Cocktail>(entityManager)
  .from(QCocktail.cocktail)
  .join(QCocktail.cocktail.recipe)
  .fetch();

方式二:显式 ON 条件连接(更灵活)

不依赖实体关系字段,直接在查询中指定关联条件。

JPQL:

entityManager.createQuery("select c from Cocktail c join Recipe r on c.name = r.cocktail", Cocktail.class)

QueryDSL:

new JPAQuery(entityManager)
  .from(QCocktail.cocktail)
  .join(QRecipe.recipe)
  .on(QCocktail.cocktail.name.eq(QRecipe.recipe.cocktail))
  .fetch();

✅ 优势:无需在实体中定义关系,适合临时关联或复杂条件
⚠️ 缺点:失去类型安全(JPQL)或需手动管理元模型


6. 一对一连接单元测试

准备测试数据:

@BeforeAll
public static void setup() {
    Cocktail mojito = new Cocktail();
    mojito.setName("Mojito");
    mojito.setPrice(12.12);

    Cocktail ginTonic = new Cocktail();
    ginTonic.setName("Gin Tonic");
    ginTonic.setPrice(10.50);

    Recipe mojitoRecipe = new Recipe(); 
    mojitoRecipe.setCocktail("Mojito"); 
    mojitoRecipe.setInstructions("经典莫吉托配方");

    entityManager.persist(mojito);
    entityManager.persist(ginTonic);
    entityManager.persist(mojitoRecipe);
    entityManager.flush();
}

验证查询结果(仅 Mojito 有配方):

@Test
public void givenCocktailsWithRecipe_whenQuerying_thenTheExpectedCocktailsReturned() {
    // JPQL + 字段连接
    Cocktail result = entityManager.createQuery(
        "select c from Cocktail c join c.recipe", Cocktail.class)
        .getSingleResult();
    assertEquals("Mojito", result.getName());

    // JPQL + ON 条件
    result = entityManager.createQuery(
        "select c from Cocktail c join Recipe r on c.name = r.cocktail", Cocktail.class)
        .getSingleResult();
    assertEquals("Mojito", result.getName());

    // QueryDSL + 字段连接
    result = new JPAQuery<Cocktail>(entityManager)
        .from(QCocktail.cocktail)
        .join(QCocktail.cocktail.recipe)
        .fetchOne();
    assertEquals("Mojito", result.getName());

    // QueryDSL + ON 条件
    result = new JPAQuery<Cocktail>(entityManager)
        .from(QCocktail.cocktail)
        .join(QRecipe.recipe)
        .on(QCocktail.cocktail.name.eq(QRecipe.recipe.cocktail))
        .fetchOne();
    assertEquals("Mojito", result.getName());
}

7. 一对多逻辑关联

现在扩展场景:一种鸡尾酒可能有多个配方(如不同流派做法)。

表结构变为 multiple_recipes,允许同一鸡尾酒名对应多条记录。

实体定义:

@Entity
@Table(name = "multiple_recipes")
public class MultipleRecipe {
    @Id
    private Long id;
    @Column(name = "cocktail")
    private String cocktail;
    @Column(name = "instructions")
    private String instructions;
    // ...
}

Cocktail 中建立一对多关系:

@OneToMany
@JoinColumn(
    name = "cocktail",
    referencedColumnName = "cocktail_name",
    insertable = false,
    updatable = false,
    foreignKey = @jakarta.persistence.ForeignKey(value = ConstraintMode.NO_CONSTRAINT)
)
private List<MultipleRecipe> recipeList;

查询有至少一个配方的鸡尾酒:

  • JPQL: select c from Cocktail c join c.recipeList
  • QueryDSL: .join(QCocktail.cocktail.recipeList)

或使用显式 ON 条件:

  • JPQL: select c from Cocktail c join MultipleRecipe mr on mr.cocktail = c.name
  • QueryDSL: .join(QMultipleRecipe.multipleRecipe).on(...)

8. 一对多连接单元测试

新增两条 Mojito 配方:

@BeforeAll
public static void setup() {
    // ... 其他数据

    MultipleRecipe r1 = new MultipleRecipe();
    r1.setId(1L);
    r1.setCocktail("Mojito");
    r1.setInstructions("古巴做法");

    MultipleRecipe r2 = new MultipleRecipe();
    r2.setId(2L);
    r2.setCocktail("Mojito");
    r2.setInstructions("美国做法");

    entityManager.persist(r1);
    entityManager.persist(r2);
    entityManager.flush();
}

测试查询:

@Test
public void givenCocktailsWithMultipleRecipes_whenQuerying_thenTheExpectedCocktailsReturned() {
    Cocktail result = new JPAQuery<Cocktail>(entityManager)
        .from(QCocktail.cocktail)
        .join(QCocktail.cocktail.recipeList)
        .fetchOne();
    assertEquals("Mojito", result.getName());
    
    // 其他查询方式验证...
}

9. 多对多逻辑关联

进一步扩展:按基酒(如朗姆、金酒)对鸡尾酒分类。

  • Cocktail 增加 category 字段(如 "Rum")
  • MultipleRecipe 增加 baseIngredient 字段

此时,多个配方可关联多个鸡尾酒(只要基酒类别匹配),构成多对多逻辑关系。

目标:查询所有“基酒类别在菜单中存在”的配方。

JPQL:

entityManager.createQuery(
    "select distinct r from MultipleRecipe r join Cocktail c on r.baseIngredient = c.category",
    MultipleRecipe.class)

QueryDSL:

QCocktail c = QCocktail.cocktail;
QMultipleRecipe r = QMultipleRecipe.multipleRecipe;

List<MultipleRecipe> recipes = new JPAQuery<MultipleRecipe>(entityManager)
    .from(r)
    .join(c)
    .on(r.baseIngredient.eq(c.category))
    .fetch();

✅ 使用 distinct 避免因一对多关系导致重复


10. 多对多连接单元测试

设置分类数据:

@BeforeAll
public static void setup() {
    mojito.setCategory("Rum");
    ginTonic.setCategory("Gin");
    r1.setBaseIngredient("Rum");
    r2.setBaseIngredient("Rum");
    // ...
}

测试查询结果:

@Test
public void givenMultipleRecipesWithCocktails_whenQuerying_thenTheExpectedMultipleRecipesReturned() {
    List<MultipleRecipe> recipes = entityManager.createQuery(
        "select distinct r from MultipleRecipe r join Cocktail c on r.baseIngredient = c.category",
        MultipleRecipe.class).getResultList();

    assertEquals(2, recipes.size());
    recipes.forEach(r -> assertEquals("Mojito", r.getCocktail()));
}

11. 总结

在无外键约束的表之间进行 JPA 查询,核心思路是:

  1. 利用 @JoinColumn + ConstraintMode.NO_CONSTRAINT 建立逻辑关系
  2. 使用 join ... on 显式指定关联条件,绕过实体定义
  3. QueryDSL 提供类型安全,减少拼写错误
  4. ⚠️ 注意 insertable/updatable = false 防止误更新
  5. 多对多场景记得去重(distinct

示例代码已上传至 GitHub:https://github.com/example/jpa-unrelated-entities (模拟地址)


原始标题:Constructing a JPA Query Between Unrelated Entities | Baeldung