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_name
和 cocktail
字段隐式关联。例如:
- 菜单中有 "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 查询,核心思路是:
- ✅ 利用
@JoinColumn
+ConstraintMode.NO_CONSTRAINT
建立逻辑关系 - ✅ 使用
join ... on
显式指定关联条件,绕过实体定义 - ✅ QueryDSL 提供类型安全,减少拼写错误
- ⚠️ 注意
insertable/updatable = false
防止误更新 - ✅ 多对多场景记得去重(
distinct
)
示例代码已上传至 GitHub:https://github.com/example/jpa-unrelated-entities (模拟地址)