JPA/Hibernate 如何复用源主键关联复合主键实体?
可以。将 Reader.bestReading 通过两列连接到 Reading:Reader.id 对应 Reading.reader_id,Reader.book_id 对应 Reading.book_id。这样,Reader 表中无需额外增加 reader_id。
为了便于控制写入,可以用普通属性 bestBookId 写入 book_id,并将 bestReading 的两个关联列都设为只读。这里的“只读”只限制通过该关联写入连接列,Reading 实体本身仍然可以修改。
关联条件是什么?
假设显式使用以下表名和列名:
reader
id 主键
book_id 选中的 Book.id,可以为空
book
id 主键
reading
reader_id 复合主键成员,同时引用 reader.id
book_id 复合主键成员,同时引用 book.id
comment
bestReading 的连接条件是:
reader.id = reading.reader_id
AND reader.book_id = reading.book_id
不同读者可以关联同一本书,因此仅凭 book_id 无法唯一定位一条 Reading,必须同时使用读者 ID 和书籍 ID。
@JoinColumn.name 指定源表中的列,referencedColumnName 指定目标表中的列,两者都应填写数据库列名。参见 JoinColumn API 文档。
如何配置实体?
下面的示例沿用 @IdClass,并显式声明表名、列名和字段访问方式。示例使用 jakarta.persistence;使用 javax.persistence 的项目应统一替换导入包。
import jakarta.persistence.*
import java.io.Serializable
@Entity
@Table(name = "reader")
open class Reader {
@field:Id
@field:GeneratedValue(strategy = GenerationType.IDENTITY)
@field:Column(name = "id")
open var id: Long? = null
@field:OneToMany(mappedBy = "reader")
open var readings: MutableList<Reading> = mutableListOf()
@field:Column(name = "book_id")
open var bestBookId: Long? = null
@field:OneToOne(optional = true)
@field:JoinColumns(
value = [
JoinColumn(
name = "id",
referencedColumnName = "reader_id",
insertable = false,
updatable = false
),
JoinColumn(
name = "book_id",
referencedColumnName = "book_id",
insertable = false,
updatable = false
)
]
)
open var bestReading: Reading? = null
}
@Entity
@Table(name = "book")
open class Book {
@field:Id
@field:GeneratedValue(strategy = GenerationType.IDENTITY)
@field:Column(name = "id")
open var id: Long? = null
@field:OneToMany(mappedBy = "book")
open var readings: MutableList<Reading> = mutableListOf()
}
@Entity
@Table(name = "reading")
@IdClass(Reading.Id::class)
open class Reading {
@field:Id
@field:ManyToOne(optional = false)
@field:JoinColumn(name = "book_id", nullable = false)
open lateinit var book: Book
@field:Id
@field:ManyToOne(optional = false)
@field:JoinColumn(name = "reader_id", nullable = false)
open lateinit var reader: Reader
@field:Column(name = "comment")
open var comment: String = ""
data class Id(
var book: Long? = null,
var reader: Long? = null
) : Serializable
}
这些类提供了无参构造,并将实体类和持久化属性声明为 open。如果项目已经通过 Kotlin 插件处理这些要求,可以按项目约定调整。
为什么要修改 Reading.Id?
原代码中的主键类保存了 Book 和 Reader 实体引用。按照 JPA 的派生标识规则,当实体的 @Id 属性是指向父实体的 @ManyToOne 时,IdClass 中的对应属性应使用父实体的主键类型。
因此,示例保留了属性名 book、reader,但将类型改为对应的 Long 主键类型。可空属性用于提供无参构造;实际定位一条 Reading 时,两项都必须有值。data class 提供的 equals() 和 hashCode() 也基于这两个 ID。规则见 Jakarta Persistence 的派生标识说明。
为什么两个 JoinColumn 都设置为只读?
Reader.id 已由主键属性映射,Reader.book_id 由 bestBookId 映射。如果再让 bestReading 写入这两列,就会产生重复写入映射的问题。
示例统一设置了 insertable = false, updatable = false,由主键属性和 bestBookId 分别负责写入。两个参数控制对应映射是否参与生成的 INSERT、UPDATE,具体语义见 JoinColumn API 文档。
如何设置或更换 bestReading?
关联列是只读的,所以仅执行 reader.bestReading = reading 不会更新数据库中的 book_id。需要修改 bestBookId,同时更新当前对象中的关联引用。
例如,在事务中,假设 reader 是受管实体,且目标 Reading 已存在:
val readerId = requireNotNull(reader.id)
val selectedReading = entityManager.find(
Reading::class.java,
Reading.Id(book = selectedBookId, reader = readerId)
) ?: error("该读者与书籍之间不存在 Reading")
reader.bestBookId = selectedBookId
reader.bestReading = selectedReading
entityManager.flush()
通过完整复合主键查找,可以避免将其他读者的 Reading 设为当前读者的选择。实际项目中,可以把这两项赋值封装到一个方法中,减少对象状态不一致的机会。
取消选择时,也要同步两个属性:
reader.bestBookId = null
reader.bestReading = null
修改标量属性不会自动重算已经加载的关联对象,因此只修改 bestBookId 后,不能假定内存中的 bestReading 已经变化。
数据库约束和保存顺序需要注意什么?
建议在数据库迁移中建立从 reader(id, book_id) 到 reading(reader_id, book_id) 的复合外键,确保选中的记录属于当前读者。仅有指向 book(id) 的外键,无法保证对应的 Reading 存在。
不要单独给 reader.book_id 添加唯一约束,否则会限制不同读者选择同一本书。关联列组合包含 reader.id,已经具备源端唯一性。
如果建立上述复合外键,两张表之间会形成循环引用。新建数据时,可以先让 bestBookId 保持为空,保存 Reader、Book 和 Reading,等目标记录写入后再设置选择。删除被选中的 Reading 前,也应先清空选择并刷新到数据库。
这里不需要给 bestReading 加 @MapsId:Reader.id 仍由 IDENTITY 生成,没有改为从 Reading 派生主键。也不能将它写成 mappedBy = "reader",因为 Reading.reader 对应的是一个读者拥有多条阅读记录的关联,无法据此区分哪一条是最佳记录。
原问题没有提供 Hibernate 版本和数据库类型。以上示例给出了映射方案,但尚未在该项目中验证。实际使用时,需要确认当前版本能正确启动映射,并核对生成的复合外键、空值关联行为和更新 SQL。
备注:内容仅供参考。
