上一篇里看了一个单向的多对一, 然后转成双向的多对一关系, 然后使用了级联操作. 现在就来详细的看看这三种关系的映射, 以及其中的细节.
相比直接使用Set, 一对多和反向映射, 现在就来看看高级一些的映射方式.
- 一对一关系 - 共享主键方式
- 一对一关系 - 外键生成器方式 - Hibernate特有
- 一对一关系 - 额外的外键关联列
- 一对一关系 - 使用关系表
- 一对多关系 - 一对多包
- 一对多关系 - 单双向列表映射
- 一对多关系 - 中间表映射
- 一对多关系 - Embedded类型的一对多关系
- 多对多关系 - 单双向多对多关系
- 多对多关系 - 使用中间Entity - Embedded是一个组件
- 多对多关系 - 三元关系
- 使用Map映射关系 - 一对多关系
- 使用Map映射关系 - 键/值表示的三元关系
一对一关系 - 共享主键方式
对于一对一关系来说, 两个Entity实际上可以认为是同一张表, 只是分别保存不同的字段. 像之前Embedded类的时候, User与Address类, 实际生成的表, Address的属性就直接扩充在User表中.
如果将Address保存成一个Entity, 会怎样呢. 好处是可以复用Address对象, 不同的User都可以对应一个Address.比如如果有一个Shipment对象, 也需要使用Address, 就不用在Shipment对象中也内嵌一个Address对象, 同时还要再输入一遍数据.
从User和Shipment的角度来说, User和Shipment 与 Address的关系是一对一关系. 即一个User或Shipment中, 就一个Address对象的引用.
映射一对一关系有很多方法, 看一下常用的映射方法.
共享主键方式, 指的是不同的表上的两个有一对一关系的行, 主键的值相等, 用例子来说, 就是要确保 user.id = address.id
这种方式的问题稍微一想就可以知道, 难点在于一定要保持两个主键相同. 先来映射一下看看, 现在的User和Address都是独立的Entity类了. Address类没有什么稀奇的:
@Entity
public class Address {
@Id
@GeneratedValue
protected Long id;
@NotNull
protected String street;
@NotNull
protected String zipcode;
@NotNull
protected String city;
.....
}
关键是User类, 如何体现一个User与Address是一对一关系:
@Entity
@Table(name = "USERS")
public class User {
@Id
protected Long id;
@OneToOne(
fetch = FetchType.LAZY,
optional = false
cascade = CascadeType.PERSIST
)
@PrimaryKeyJoinColumn
protected Address shippingAddress;
......
}
这个User类有如下几个特点:
- @Id之后没有主键生成策略,这是为数不多的使用应用(Hibernate)来控制主键的方式. 当然, 构造器也可以是一个带有id属性的构造器, 不过既然直接存取字段, 构造器也不是必须的.
@OneToOne
注解要加在Address属性上, 一对一关系的默认加载方式是EAGER
@OneToOne
中的optional = false, 表示一个User必须有一个Address, 不能为null.
@PrimaryKeyJoinColumn
注解也加在Address属性上, 这说明了User主键, 要采用Address类中生成的主键, 这实际上是User->Address单向的一对一关系.
来写一个测试看看究竟如何操作, 以及生成的表:
@Test
public void test() {
EntityManagerFactory emf =
Persistence.createEntityManagerFactory("HelloWorldPU");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
Address address = new Address();
address.setCity("shanghai");
address.setStreet("zhroad");
address.setZipcode("100000");
User user = new User();
user.setUsername("cony");
user.setShippingAddress(address);
em.persist(user);
tx.commit();
}
看上去应该没问题, 但是一运行, 就报错了, 提示如下:
ids for this class must be manually assigned before calling save(): cc.conyli.model.chapter8.onetoone.User
这说明, 不能这么想当然的持久化, 因为没有设置主键生成策略, 必须自己手工指定, 这就需要先持久化address对象, 从其中获取id之后, 再设置给user对象才行:
tx.begin();
em.persist(address);
user.setId(address.getId());
em.persist(user);
//这一行可以忽略
user.setShippingAddress(address);
tx.commit();
实际上, 在上边三条执行完之后, user.setShippingAddress(address);
执行不执行都不影响Hibernate写入, 因为底层已经写完了外键的数值. 只是影响Java代码能不能获取关联的对象.
共享主键有如下几个问题:
- Address类必须有一个在插入前生成主键的生成器, 而且必须先持久化, 否则无法获取主键.
- 只有值不是null的时候, 是否选择延迟加载才有意义.一对一关系的默认加载方式是EAGER, 但是如果值是null, 还是会去数据库里查Address看是不是null. 所以设置成optional=true就无法启用延迟加载.
- 这种onetoone是单向关系, 但很多时候需要双向关系
这三个问题,第一个是无法解决的; JPA规范对于一个null值无法启用加载代理, 所以也无法解决. 唯一能解决的就是第三个问题, 可以使用一个特殊的生成器, 来让关系变成双向.
一对一关系 - 外键生成器方式 - Hibernate特有
只要是双向关系, 必定有一方要有一个mappedBy属性去指定另外一方的属性名称.
这里将两个类修改如下, 先看User类:
@Entity
@Table(name = "USERS")
public class User {
@Id
@GeneratedValue
protected Long id;
@OneToOne(
mappedBy = "user", optional = false,
cascade = CascadeType.PERSIST
)
protected Address shippingAddress;
}
User类去掉了@PrimaryKeyJoinColumn
注解, 在id上加上了@GeneratedValue
, 表示User的主键自动生成. Address类的主键如何对应User的主键呢:
@Entity
public class Address {
@Id
@GeneratedValue(generator = "addressKeyGenerator")
@org.hibernate.annotations.GenericGenerator(
name = "addressKeyGenerator",
strategy = "foreign",
parameters =
@org.hibernate.annotations.Parameter(
name = "property", value = "user"
)
)
protected Long id;
@OneToOne(optional = false)
@PrimaryKeyJoinColumn
private User user;
}
由于是双向关系, 所以Address类中也有了一个user属性, 这个属性被User类的address给 mappedBy了, 还都带着@OneToOne, 所以二者对应上了.
上边使用了一个id生成器, id的策略是"foreign", 这个策略仅仅用在双向一对一的生成关系中.下班的@Parameters表示对应的属性名称是User user. 也就是说, address 每一次生成id, 实际上是从user对象中取id过来当成自己的id.
现在的user属性被加上了@PrimaryKeyJoinColumn
, 表明这一列就是外键列了, 同时还是not null, 说明了User和Address生命周期的一致性.
由于User还有级联删除, 现在只需要给user设置好address属性, 然后进行保存即可, user的id会自动写入到address对象的id上去. 写一个测试看看:
tx.begin();
address.setUser(user);
user.setShippingAddress(address);
em.persist(user);
tx.commit();
只要先将user和address关联起来, 然后再持久化, 就OK了, address的id会根据user的主键来确定. 在实际插入的时候, 先插入user对象, 然后根据user的id再插入address对象.
相比之下, 推荐使用Hibernate的主键通过外键生成的方式, 比较容易操作和理解.
一对一关系 - 额外的外键关联列
上边的两种方法, 虽然也是外键关联, 但是两个表的主键都参与了关联. 如果不共享主键, 第一种方法的三个问题中的第一个就解除了. 实际做法也很简单, 让address的主键关联到user的一列非主键, 然后将user这一列设置为unique.
这样就保证不会有相同的address对应到同一个user上. 由于主键也是unique, 其实这种方法的本质和前两种也没有太大区别.
先来看User类, 不使用@PrimaryKeyJoinColumn
来定义一个被外键关联的列, 而是使用普通的@JoinColumn来定义, 其他基本类似:
@Entity
@Table(name = "USERS")
public class User {
@Id
@GeneratedValue
protected Long id;
@OneToOne(
mappedBy = "user", optional = false,
cascade = CascadeType.PERSIST,
fetch = FetchType.LAZY
)
@JoinColumn(unique = true)
protected Address shippingAddress;
}
@JoinColumn
的意思就是表示这是一个外键列(foreign key). 可以使用LAZY加载, 因为不会为null.
而Address就没有任何要求, 是一个普通的Entity类:
@Entity
public class Address {
@Id
@GeneratedValue
protected Long id;
@NotNull
protected String street;
@NotNull
protected String zipcode;
@NotNull
protected String city;
}
测试代码也简单了很多, 只需要设置User的address:
tx.begin();
user.setShippingAddress(address);
em.persist(user);
tx.commit();
由于之前是address的主键关联user的主键, 现在@OnetoOne写在User里而且没有mappedBy, 则外键列就是shippingAddress属性.
建表的语句是:
Hibernate:
create table Address (
id int8 not null,
city varchar(255),
street varchar(255),
zipcode varchar(255),
primary key (id)
)
create table USERS (
id int8 not null,
username varchar(255),
shippingAddress_id int8 not null,
primary key (id)
)
alter table if exists USERS
add constraint UK_ob48mbnfmufd417fo9swuhfbv unique (shippingAddress_id)
alter table if exists USERS
add constraint FK8b81owquby3hwghtf9oyccwpm
foreign key (shippingAddress_id)
references Address
可以看到User的外键关联到Address的主键,同时外键列又是unique的, 所以本质和第二种方法没有区别, 只是不用主键直接关联, 自然就要多一列了. 这种方式配置注解比较简单, 也易于理解, 同时不操作主键, 减少了很多问题.
一对一关系 - 使用关系表
一对一关系, 如果某一边是一个null, 就会很麻烦, 而且关联在一起. 更好的解决方案是通过一个中间表, 其中的一行表示一个对应关系, 没有则表示没有关系.
使用了中间表之后, 就不一定要把两个对象这么紧密的捆绑在一起了. 但是这里的意义有变化, 即两个对象的关系可能是可选的一对一关系, 即要么没有对应, 要么一对一.
这个意义就和之前强行约束的一对一关系有区别了.
看代码吧, 假如现在有一个User和Address的关系是: User要么有一个Address, 要么没有, 则一对一通常设置在UML关系中的0..1的1的那一边, 即箭头出发, 而不是指向的那一边, 也就是User类中. 再次强调注意此处一对一意义的变更.
@Entity
@Table(name = "USERS")
public class User {
@Id
@GeneratedValue
protected Long id;
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
@JoinTable(
name = "USER_ADDRESS",
joinColumns = @JoinColumn(name = "USER_ID"),
inverseJoinColumns =
@JoinColumn(name = "ADDRESS_ID", nullable = false, unique = true)
)
protected Address shippingAddress;
}
这里的一对一关系依然设置在shippingAddress上, 但是这里不再使用主键关联或者@JoinColumn, 而是使用了@JoinTable注解. 这个注解隐藏了中间表, 并没有中间表对应的类, 但是必须设置中间表的名称.
这样shippingAddress其实不会出现在User类上, 而是Hibernate会根据@JoinTable来创建一个中间表.
其中的joinColumns表示关联到当前类也就是User类的外键列的属性, inverseJoinColumns则表示关联到另外一个表(也就是Address)的外键列的属性. 这里还给其设置了unique, 说明要么没有, 要么就对应一个,不能重复.
测试代码其实和上一个一样, 都只需要设置user的address属性就可以了.
看一下创建的SQL语句:
Hibernate:
create table Address (
id int8 not null,
city varchar(255),
street varchar(255),
zipcode varchar(255),
primary key (id)
)
create table USER_ADDRESS (
ADDRESS_ID int8 not null,
USER_ID int8 not null,
primary key (USER_ID)
)
create table USERS (
id int8 not null,
username varchar(255),
primary key (id)
)
Hibernate:
alter table if exists USER_ADDRESS
add constraint UK_6v1dwl45nt34pce4p648i1gtx unique (ADDRESS_ID)
alter table if exists USER_ADDRESS
add constraint FKocjk8pkyyoeqytsjegryu4ldl
foreign key (ADDRESS_ID)
references Address
alter table if exists USER_ADDRESS
add constraint FKqqeoct76ir4xbb8lvyycxus2s
foreign key (USER_ID)
references USERS
可见创建了中间表. 插入的时候也完全OK. 如果一个关系在表里查不到, 就说明User对象没有对应的Address对象.
一对多关系 - 一对多包
多元Entity关联, 指的就是一个类中有一个实体引用的集合. 之前看过了基于Set的双向一对多关联, 实际上在能够用简单单双向一对多的情况下能够完成的事情, 没有必要使用更加复杂的映射. 因为如果不愿意通过Java代码来操作,完全可以编写一个查询, 直接查出结果.
所以基于Set的一对多关联具有普遍的意义. 现在看一些更复杂的场景.
在一些情况下, 可以考虑使用包来进行一对多映射, 包在集合中效能最高, 不用进行重复检查, 也不保存顺序. 所以将新元素添加到包里, 不会立刻触发加载.
所以如果要给一个元素映射成@OneToMany(mappedBy = "xxx")
,包是最佳的选择.
在代码上很简单, 只需要把之前基于Set的映射变成特殊语义的Collection与ArrayList即可, 这里直接写一个测试:
@Test
public void testAdd() {
EntityManagerFactory emf =
Persistence.createEntityManagerFactory("HelloWorldPU");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
Item item = new Item();
item.setName("cony");
Bid bid1 = new Bid();
bid1.setAmount(new BigDecimal("3.04"));
bid1.setItem(item);
Bid bid2 = new Bid();
bid2.setAmount(new BigDecimal("9.83"));
bid2.setItem(item);
tx.begin();
em.persist(item);
em.persist(bid1);
em.persist(bid2);
em.persist(bid1); //这一行没有持久化效果
tx.commit();
}
Bag中的重复, 指的是值的重复, 而不是引用对象的重复. 虽然Bag叫做重复, 但反复添加同一个对象的引用, 会被Hibernate知道, 从而不会持久化进去.
实际上这里查看代码的时候, 会发现如果从数据库中取出item, 调用 item.getBids().add(Bid b), 由于之前说的不会检查顺序和重复, 也就不会触发SELECT, 因此比较高效.
一对多关系 - 单双向列表映射
这里的列表指的是保存加载顺序的List = ArrayList, 注意与可排序的List不是一个概念,之前已经知道, 使用这种语义会让Hibernate把顺序也一并保存. 这里就是要持久化带有顺序的列表.
首先来看单向映射, 即使用List来映射Item与Bid之间的关系, 由于上一段说的, 需要定义一个存储顺序的列, 因此这里要使用一个新注解 @OrderColumn
来给顺序列提供信息:
@Entity
public class Item {
@Id
@GeneratedValue
protected Long id;
@NotNull
protected String name;
@OneToMany
@JoinColumn(
name = "ITEM_ID",
nullable = false
)
@OrderColumn(
name = "BIDS_long", // Defaults to BIDS_ORDER
nullable = false
)
public List<Bid> bids = new ArrayList<>();
}
这个映射要注意, 只要不是双向映射, 一对多的情况下, 不管映射在哪一边, 外键可是肯定在一对多的多的那一侧, 这里虽然是把@OneToMany标注在Item类上, 但是实际上会在Bid中创建一个外键.
此后的@JoinColumn注解就是在定义这个外键列的名称,叫做ITEM_ID. 之后还定义了一个OrderColumn, 也是定义在Bid中的. 可见上边注解如果加在一个集合引用上, 实际上是对另外一个类起作用, 而不是当前类.
这么映射好之后, Bid类什么都不需要做, 是最干净的Bid类, 连Item引用都没有:
@Entity
public class Bid {
@Id
@GeneratedValue
protected Long id;
@NotNull
protected BigDecimal amount;
......
}
看生成的SQL语句, 就会知道外键, 额外的排序列都全部加到看上去一清二白的Bid表中:
Hibernate:
create table Bid (
id int8 not null,
amount numeric(19, 2),
ITEM_ID int8 not null,
BIDS_long int4 not null,
primary key (id)
)
alter table if exists Bid
add constraint FK884gyguvo3jcbca0v78w95l3k
foreign key (ITEM_ID)
references Item
可见创建了一个表示列表中顺序的列, 还有一个外键关联到Item. 这样就在Bid表中存储了关联到一个集合中的带顺序的所有Bid. 看来想要用好这个映射注解, 真的要很了解才行, 加在对方的关系和自己的关系上真的很不同.
实际上通过写入可以发现, 外键和顺序列都是Hibernate自己控制的, 我们要做的只是Java代码中将bid添加进Item的集合中即可.
目前Bid中没有任何Item的引用, 添加Item引用也就意味着要将关系改成双向的, 只需要添加一个Item属性即可. 但是要注意, 这个Item属性其实也是一个外键, 之前已经添加过了一个外键, 所以这个Item属性其实就是在Item中通过@OneToMany和@JoinColumn弄出来的外键列:
@ManyToOne
@JoinColumn(name = "ITEM_ID", updatable = false, insertable = false)
private Item item;
这里要注意的是, 上来似乎会想到用@ManyToOne(mappedBy = ...)
, 但实际上是不行的, 不能mappedBy一个集合对象. 而且因为外键是存在于Bid类中, 所以Bid是外键的所有类, 不能使用mappedBy, 只有不存在外键的类上才能使用mappedBy.
在原来的一对多关系中, 就是如此, 要在没有外键的类中使用mappedBy.
这里还有一个特殊的地方, 就是对ITEM_ID定义了两次, 所以在Bid类中,需要将其设置为不能更新也不能插入. 实际上, Hibernate依赖于在@ManyToOne上定义的JoinColumn, 如果同时存在@OneToMany和@ManyToOne关系, Hibernate会优先使用@ManyToOne上的JoinColumn定义, 而会忽略@OneToMany上的.
这个List的映射看上去像一个hack而不是正常的映射. 实际上在现实开发中, 保存Java的ArrayList的顺序几乎不可能遇到, 因为本身就不是一个好主意, 顺序应该按照意义来排序, 而不是简单的强制顺序.
一对多关系 - 中间表映射
采用中间表映射, 暗示可以有不对应的关系. UML类图中的 0..* 就是一种optional关系. 使用这种一对多关系的时候要注意.例如一个User可能不会买Item, 也可能买多个Item, 中间表有两个外键, 一个关联到多方Item, 一个关联到一方User, 要让多方的外键是unique. 这样就不会出现Item有重复的情况, 即Item只要出现, 只能对应一个多方. 如果不出现, 表名该Item和User之间没有对应关系. User方的外键无需unique, 因为一个user可以买多个item.
还记得吗, 表间关系先从多的那方做起, @ManyToOne是没有mappedBy的, 因此就从Many的这一方做起, 也就是Item类中去定义中间表:
@Entity
public class Item {
@Id
@GeneratedValue
private long id;
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
@JoinTable(
name = "ITEM_BUYER",
joinColumns = @JoinColumn(name="ITEM_ID",nullable = false),
inverseJoinColumns = @JoinColumn(name="USER_ID",nullable = false)
)
private User user;
}
User类则是一个mappedBy对应过来, 再弄一个级联保存, 已经熟悉了:
@Entity
@Table(name = "USERS")
public class User {
@Id
@GeneratedValue()
protected Long id;
protected String username;
@OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST)
private Set<Item> items = new HashSet<Item>();
其实细心的话, 可以发现这个与@OneToOne没有本质区别, 都是仅会让中间表的某一个外键列变成unique. 这是因为本质上, 单向的一对一和多对一在对应关系上, 在多的那一方是看不出来的,都是一对一的关系.
一对多关系 - Embedded类型的一对多关系
回到之前的例子, 即Address作为一个Embedded类型嵌入到User中, 如果Address有一个一对多关系, 关联到多个Shipment Entity. 这种情况下就是在Embedded类型其中再放入一个一对多关系, 由于Embedded作为值类型的本质, 会被拆解到User中, 所以存在于Embedded类中的一对多关系, 就是将这个一对多关系移植到对应的被嵌入类型也是User中.
User嵌入Embedded的套路很方便了, 没有特殊的要求, User都不用做任何设置, Address也是一个普通的Embedded类型, 唯一就是要看Address中的一对多关系:
@Embeddable
public class Address {
@NotNull
@Column(nullable = false)
private String street;
@NotNull
@Column(nullable = false)
private String city;
@OneToMany
@JoinColumn(name = "ADDRESS_ID")
private Set<Shipment> shipments = new HashSet<>();
}
这里要注意的是, @OneToMany
只能从设置在Address类中, 根据定义, Shipment不能关联到是Embedded类的Address.
这么操作之后, 由于Address内嵌到User类, 所以这个名称为"ADDRESS_ID"的外键, 实际上是关联到User的ID, 可以看如下的建表语句:
Hibernate:
create table Shipment (
id int8 not null,
destination varchar(255) not null,
ADDRESS_ID int8,
primary key (id)
)
alter table if exists Shipment
add constraint FKn585ithky2bgm43ct0v90c3yu
foreign key (ADDRESS_ID)
references "User"
既然是在@OneToMany这边规定映射关系和外键, 自然也可以用中间表, 套路还是一样的, 这里的中间表, 实际上是代表User和Shipment的中间表:
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
@JoinTable(
name = "USER_SHIPMENT",
joinColumns = @JoinColumn(name = "USER_ID", nullable = false),
inverseJoinColumns = @JoinColumn(name = "SHIPMENT_ID", nullable = false)
)
private Set<Shipment> shipments = new HashSet<>();
多对多关系 - 单双向多对多关系
多对多关系, 就两个Java类来说, 就是互相持有另外一个类的集合, 映射成数据库的话, 通常方法都是使用一个中间表. JPA使用@ManyToMany来映射这个关系, 同时需要指定@JoinTable.
与之前的关系一样, 一个是映射, 一个是反映射, 即使用(mappedBy), 一般来说多对多关系的两个类是平等的, 所以在哪边设置都无所谓, 只要另外一边写好mappedBy就可以.
先来看单向的映射, 其实套路和之前使用中间表没有任何区别:
@Entity
public class Item {
@Id
@GeneratedValue
protected Long id;
protected String name;
@ManyToMany(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
@JoinTable(
name = "CATE_ITEM",
joinColumns = @JoinColumn(name ="ITEM_ID",nullable = false),
inverseJoinColumns = @JoinColumn(name = "CATE_ID", nullable =false)
)
private Set<Category> categories = new HashSet<>();
}
生成中间表的细节都知道了, 由于多对多关系只用来表示有没有关系, 所以Set非常适合表示多对多关系, 正常操作也不会让中间表产生重复列, 不过之前学习SQL的时候, 知道这里可能会将中间表的两个外键进行联合unique约束, 以确保唯一性. 这里使用了级联删除, 对于多对多关系, 级联中的ALL, REMOVE和JPA的orphan deletion没有任何意义.
改成双向的也很简单, 只要在Category上mappedBy一下就可以了:
@Entity
public class Category {
@Id
@GeneratedValue
protected Long id;
private String categoryName;
@ManyToMany(mappedBy = "categories")
private Set<Item> items = new HashSet<>();
}
不过在保存的时候要注意, 还是要从另外一侧保存, 即没有mappedBy的一方, mappedBy的一方是只读的, 即:
//这些代码只会保存item和category, 无法保存关系
category1.getItems().add(item1);
category2.getItems().add(item1);
category2.getItems().add(item2);
category3.getItems().add(item1);
category3.getItems().add(item2);
tx.begin();
em.persist(category1);
em.persist(category2);
em.persist(category3);
tx.commit();
要在Item一侧保存:
item1.getCategories().add(category1);
item1.getCategories().add(category2);
item1.getCategories().add(category3);
item2.getCategories().add(category2);
item2.getCategories().add(category3);
tx.begin();
em.persist(item1);
em.persist(item2);
tx.commit();
这样级联保存才能生效, 还会把中间表信息自动写入.
多对多关系 - 使用中间Entity - Embedded是一个组件
@JoinTable
本质上变成一个表存在了数据库中, 只不过Hibernate替我们隐藏了这个中间表, 没有直接对应的Java类, 而且这个中间表仔细观察可以发现, 竟然没有主键列. 但如果要在中间表上添加额外的信息, 比如创建时间, 和其他要素, 这个时候可能要使用一个中间实体, 才能更好的管理这个中间表的细节.
现在需要在中间表中, 额外保存两个字段, 一个是addBy, 字符串, 表示由哪个用户添加, 一个是addedOn, 表示新增的时间. 这个中间表给起一个名称叫做CategorizedItem.
能想到的最直接的办法, 就是创建一个CategorizedItem类, 里边两个字段设置外键关联, 然后再添加两个字段. 且慢, 但是要如何更新和自动控制写入呢. 这里有一个崭新的思路, 就是刚才说的, 原始的中间表没有主键列, 这意味着中间表可以作为一个Embedded类型, 这个时候其实就是一个组件的概念.
在CategorizedItem中创建一个由两个外键组成的组件, 而且将这个组件当成联合主键, 这样就生成了关系, 之后再添加额外的字段就可以了. 由于是一个全新的概念, 来看看怎么配置:
@Entity
@Table(name = "CATEGORY_ITEM")
@org.hibernate.annotations.Immutable
public class CategorizedItem {
@Embeddable
public static class Id implements Serializable {
//关联到category的外键列
@Column(name = "CATEGORY_ID")
private long categoryId;
//关联到item的外键列
@Column(name = "ITEM_ID")
private long itemId;
//值类型一定要编写hashcode和equals
public boolean equals(Object o) {
if (o instanceof Id) {
Id that = (Id) o;
return this.categoryId == that.categoryId
&& this.itemId == that.itemId;
}
return false;
}
public int hashCode() {
return (int) (categoryId * 2198 + itemId * 7988);
}
public Id() {
}
public long getCategoryId() {
return categoryId;
}
public void setCategoryId(long categoryId) {
this.categoryId = categoryId;
}
public long getItemId() {
return itemId;
}
public void setItemId(long itemId) {
this.itemId = itemId;
}
}
//两个列组成的组件当成联合主键
@EmbeddedId
public Id id = new Id();
//给刚才组件中的两个列添加外键关联, 关联关系注意是ManyToOne, 因为是一个外键
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "CATEGORY_ID", insertable = false, updatable = false)
private Category category;
@ManyToOne(cascade =CascadeType.PERSIST)
@JoinColumn(name = "ITEM_ID", insertable = false, updatable = false)
private Item item;
@Column(updatable = false)
@NotNull
protected String addedBy;
@Column(updatable = false)
@NotNull
protected Date addedOn = new Date();
public static CategorizedItem createLink(Category category, Item item, String addedBy) {
CategorizedItem result = new CategorizedItem();
result.addedBy = addedBy;
result.item = item;
result.category = category;
result.id.categoryId = category.getId();
result.id.itemId = item.getId();
return result;
}
}
上边只保留了关键的列. 这里很多东西, 一个一个解释:
@Embeddable
注解的内部类, 这里将其看成一个组件, 由于内嵌类最后就是定义列, 所以这里一次性定义了两个列. 注意value type一定要编写hashCode和equals方法. 目前这两个列还是普通的列.
@EmbeddedId
表示使用这个组件当成主键, 就是一个联合主键, 生成的语句中会有primary key (CATEGORY_ID, ITEM_ID)
- 然后是两个
@ManyToOne
定义中间表与两个实体类的多对一关系(实体对应中间表则是一对多, 这和普通中间表本质上没区别), 由于中间表的外键都是一对多, 而且不是新创建外键, 所以重复使用组件内的两个列名添加外键约束. 这里注意要设置成不能更新和插入, 因为在组件中已经映射过名称叫做"CATEGORY_ID"和"ITEM_ID"的列, 只要重复映射, 一定只能有一个地方读写, 其他地方都要设置成只读, 否则会报重复映射错误.
- 由于设置了只读, 所以必须要手工每次创建关系的时候, 将两个Id都给指定掉, 所以编写了一个
createLink
工厂方法用于创建关联.
看到这里, 其实对Hibernate的这些注解理解更深了, 有如下感触:
- 映射类的时候, 首先需要映射好全部的基础属性列. 映射列, 其实指的是将一个属性映射到一个列名, 无论写不写@Column(name = "xxx), 脑子里都要想到,在这个类的映射空间中, 已经有一个名称被占用了, 对应数据库一个列. 如果其他地方再在注解中映射这个名称, 就是重复映射, 一定要注意配置是否可读来解决映射问题
- 映射关系, 相当于添加约束, 可以用新的列来添加约束, 只要指定一个新的名称即可. 也可以给已经存在的列(也就是名称)来添加约束, 这个时候就是重复映射, 一定要注意解决.
- 所有的注解, 都采取先映射好基础类型的各个列, 再添加关系, 再映射关系所代表的列这样一个途径, 就不会错了. 本质一定要抓住.
Item和Category的类都要修改成外键反映射到中间实体上:
@Entity
public class Item {
@OneToMany(mappedBy = "category")
private Set<CategorizedItem> categories = new HashSet<>();
}
@Entity
public class Category {
@OneToMany(mappedBy = "item")
private Set<CategorizedItem> items = new HashSet<>();
}
在编写这个测试的时候, 很显然, 不像之前一定要直接将Item和Category对应起来然后一起保存, 有了中间实体类, Item和Category可以独立存在, 需要的时候创建一条关系再持久化即可.
这里注意由于外键列如果不先持久化, 就不知道id, 而且中间类同时关联两个列, 一定要手工设置好id, 也就是利用createLink
方法:
tx.begin();
//先持久化各个Item和Category对象
em.persist(category1);
em.persist(category2);
em.persist(category3);
em.persist(item1);
em.persist(item2);
em.persist(item3);
//创建CategorizedItem对象, 其中的id要手工设置
CategorizedItem link1 = CategorizedItem.createLink(category1, item2,"owl");
CategorizedItem link2 = CategorizedItem.createLink(category2, item3,"kiwi");
CategorizedItem link3 = CategorizedItem.createLink(category3, item1, "cony");
em.persist(link1);
em.persist(link2);
em.persist(link3);
tx.commit();
建表语句还是需要看一下, 才明白这映射的关系:
Hibernate:
create table CATEGORY_ITEM (
CATEGORY_ID int8 not null,
ITEM_ID int8 not null,
addedBy varchar(255),
addedOn timestamp,
primary key (CATEGORY_ID, ITEM_ID)
)
alter table if exists CATEGORY_ITEM
add constraint FKjip0or3vemixccl6vx0kluj03
foreign key (CATEGORY_ID)
references Category
alter table if exists CATEGORY_ITEM
add constraint FKf1uerpnmn49vl1spbbplgxaun
foreign key (ITEM_ID)
references Item
联合主键加两个外键. 这里多说一下, 如果设置不是主键外的联合unique的话, 使用@Table(uniqueConstraints = {列名})
即可. 如果是联合主键, 则可以考虑使用组件.
多对多关系 - 三元关系
刚才的CategorizedItem的User属性是一个基本类型, 如果User也是一个外键, 代表User用户会怎么样.
其实映射方法有很多, 但是比起映射更重要的是, 要确实搞清楚三元关系更加重要.
在这里的User用户, 表示的是"创建某一个具体item-category关系"的人. 因此很简单, user是可以重复的. 似乎一个普通的外键就可以做到
item-category是联合主键, 这意味者每一行item-category关系都是不可能重复的, 即item-category是候选码. 候选码的超集依然是候选码. 因此, 将user外键, item外键, category外键三个组成一个联合组件映射似乎也不错.
两相比较之下, 还是将User也映射成一个多对一的主键比较好, 因为相比三联主键, 性能要更好一些. 代码也会更简单明了一些.
这里代码就不具体放了. 三元关系加以了解就可以.
使用Map映射关系 - 一对多关系
用Map来映射一对多关系的理论是, 如果一个Map的键是基本类型, 而值是一个Entity的引用, 这就是一个一对多关系.
还是以Item和Bid关系为例, Item有一个Map属性, 里边的键是Bid类的id, 值是对象的引用. 那么这就是一个一对多关系. 因为本来的一对多关系, 也是通过id关联起来的.
现在还是假设Bid已经配置好了@ManyToOne, 关键是Item要用Map来映射:
@Entity
public class Item {
@Id
@GeneratedValue
protected Long id;
protected String name;
@MapKey(name = "id")
@OneToMany(mappedBy = "item", cascade = CascadeType.PERSIST)
public Map<Long, Bid> bids = new HashMap<>();
}
红色的是新注解, 只用在Map类型上, name表示Map的值对应的Entity的主键属性名称(java类的属性名称), 如果不指定name, 会自动匹配Entity的主键属性. 如果指定的不是主键属性, Hibernate不会检查该列是否为unique, 需要自己来从其他地方确保键唯一.
看一下实际建表的语句:
Hibernate:
create table Bid (
id int8 not null,
amount numeric(19, 2),
item_id int8,
primary key (id)
)
create table Item (
id int8 not null,
name varchar(255),
primary key (id)
)
alter table if exists Bid
add constraint FKofartcioobwpek3qex4cmturt
foreign key (item_id)
references Item
结果惊奇的发现, 建表方面没有任何变化. 所以使用Map映射一对多关系, 其实只是Hibernate将查询到的数据包装成的类型不同, 对于底层表, 既然能简化成一个外键搞定, 自然不需要任何变化.
使用Map映射关系 - 键/值表示的三元关系
这个玩意我琢磨了一会终于明白了. Map本身就是两个对象的关系, Map所在的对象与Map之间 又是一层对应关系. 在一对多中, 键使用了基本类型, 关系就变成了两个, 即Map宿主类与Map中多个数据的关系.
前边定义了Category-Item的关系, 可以由一个User来添加. 可见是三个对象的关系, 现在有了三个东西的关系, 只需要再往前一步, 将Map的key, 也设置成为一个实体对象.
于是Category-Item的关系, 其实可以写成Category含有一个以Item为键的关系. 仔细想一想, 二者整体是多对多关系, 就单独的一个category对象来说, 还是一对多, 因为其他持有一个item的集合.
然后只要将Map的值改成User, 这就描述出了三元关系.
相比刚才的映射是基本类型的情况下, 不是基本类型就相当于一个关联, 因此不能使用@MapKey(name = "id"), 而要使用@MapKeyJoinColumn(name = "ITEM_ID"), 来让这个也生成关联, 映射方式如下:
@Entity
public class Category {
@ManyToMany(cascade = CascadeType.PERSIST)
@MapKeyJoinColumn(name = "ITEM_ID")
@JoinTable(
name = "CATEGORY_ITEM",
joinColumns = @JoinColumn(name = "CATEGORY_ID"),
inverseJoinColumns = @JoinColumn(name = "USER_ID")
)
protected Map<Item, User> itemAddedBy = new HashMap<>();
}
@MapKeyJoinColumn(name = "ITEM_ID")
指定的是Item表的主键列, 因为键不是基本类型了, 所以相当于一个外键关联过去. 这里可以省略@MapKeyJoinColumn的name属性, 会生成一个默认的外键列关联到Item表.
这里其实可以想到的是, 创建的中间表会有三个外键, 和刚才的三元关系表没有任何区别. 只是Hibernate会根据Item和User的id, 将结果组装成map的形式.
用Map去映射总的来说还是比较搞的,感觉组装时间应该也比普通映射会长.
这漫长的一章终于结束了, 对Hibernate的认识尤其是各种注解背后的意味了解更深了, 中间在碰到重复注解的时候又理解了一些新东西.
现在拿着UML类图总算心里有谱知道如何映射了.