在这一课中,我们将介绍一对多的关系,并学习orphan removal属性。

为了显示一对多的关系,我们将模拟许多球员可以注册参加一个锦标赛的情况。我们将创建一个锦标赛(tournament)表和一个注册(registration)表来模拟这种关系。

单向的一对多关系意味着只有一方维护关系细节。因此,给定一个Tournament实体,我们可以找到Registration人数,但我们不能从Registration实体中找到Tournament的细节。

一对多的单向关系

一对多的单向关系

  1. 为了模拟一对多的关系,创建一个新的包onetomany.uni,并定义一个有三个字段的Tournament类:idnamelocationid字段是主键。我们还可以保存其他细节,如比赛发生的日期,比赛的场地类型,以及比赛的轮数等。

    package io.datajek.databaserelationships.onetomany.uni;
    
    @Entity
    public class Tournament {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private int id;
        private String name;
        private String location;
        //getters and setters
        //constructor
        //toString method
    }
    
  2. 接下来,定义Registration类,目前只有一个字段,idid字段是该表的主键。我们以后会添加更多的字段。

    Registration类可以存储关于注册日期的信息,球员注册的比赛类型(单打/双打),以及分配给球员的等级(种子)等。

    package io.datajek.databaserelationships.onetomany.uni;
    
    @Entity
    public class Registration {
        @Id
        @GeneratedValue(strategy=GenerationType.IDENTITY)
        private int id;
        //getters and setters
        //constructor
        //toString method
    }
    

    <aside> 📢 由于球员注册参加比赛,注册对象应该与球员对象相关联。我们将在下一课中讨论这种关联。

    </aside>

  3. 现在,我们将更新Tournament类以显示注册情况。由于一个锦标赛可以有多个注册者,我们将添加一个RegistrationList作为一个新的字段。

    public class Tournament {
        //...
        private List<Registration> registrations = new ArrayList<>();
        //generate getter and setter methods 
        //update constructor & toString()
    }
    

@OneToMany

  1. Tournament类与Registration类有一对多的关系,因为一个锦标赛可以有多个注册者。这可以通过@OneToMany注解来模拟。在一对多的关系中,一方的主键被置于多方的外键中。

    @JoinColumn注解表明这是关系的所有方。 tournament_id将被添加为registration表中的外键列。

    @OneToMany
    @JoinColumn(name="tournament_id")
    private List<Registration> registrations = new ArrayList<>();
    

    @JoinColumn注解

    @JoinColumn注解

    <aside> 📢 在没有@JoinColumn注解的情况下,Hibernate为一对多的关系创建一个包含两个表的主键的连接表。

    </aside>

    如果应用程序被运行,它会创建如下所示的数据库结构。这里tournament_id是外键列。我们可以使用H2控制台(在http://localhost:8080/h2-console,连接URL为jdbc:h2:mem:testdb)来验证这一点。

    表结构

    表结构

级联类型

  1. 接下来,我们将为这种关系选择级联类型。当一个锦标赛被删除时,我们将同时删除相关的注册。这可以通过选择CascadeType.ALL来实现。

    @OneToMany(cascade=CascadeType.ALL)
    @JoinColumn(name="tournament_id")
    private List<Registration> registrations = new ArrayList<>();
    
  2. 为了建立锦标赛和注册之间的关联,我们将在Tournament类中添加一个方法,将Registration对象分配给Tournament对象。

    public void addRegistration(Registration reg) {
        registrations.add(reg);
    }
    
  3. 现在我们将在适当的包中为RegistrationTournament创建数据接入层、服务层和控制器类。数据接入层被命名为TournamentRepositoryRegistrationRepository,并扩展了JpaRepository接口。

    REST控制器TournamentControllerRegistrationController@RequestMapping分别为/tournaments/registrations。控制器类调用服务层类TournamentServiceRegistrationService中的方法。

    所有上述的接口和类都显示在下面的代码压缩包中。

  4. 我们需要在TournamentController类中设置一个PUT映射,将一个注册信息分配给一个锦标赛。带有/{id}/registrations/{registration_id}映射的addRegistration方法将一个带有registration_id的注册加入到以id为键的锦标赛中。

    @PutMapping("/{id}/registrations/{registration_id}")
    public Tournament addRegistration(@PathVariable int id, @PathVariable int registration_id) {
        Registration registration = registrationService.getRegistration(registration_id); 
        System.out.println(registration);
        return service.addRegistration(id, registration);
    }
    

    TournamentService类中相应的服务层方法显示。

    public Tournament addRegistration(int id, Registration registration) {
        Tournament tournament = repo.findById(id).get();
        tournament.addRegistration(registration);
        return repo.save(tournament);
    }
    

    此时的所有代码:

    3.zip

  5. 为了测试应用程序,首先使用以下POST请求向/tournaments添加两个锦标赛。

    {
        "name": "Canadian Open",
        "location": "Toronto"
     }
    
    {
        "name": "US Open",
        "location": "New York City"
     }
    

    接下来,我们将通过向/registrations发送带有空主体的POST请求来添加四个注册项目。

    {}
    

    在这四个注册中,我们将把一个与第一场比赛相关联,三个与第二场比赛相关联。这可以通过发送以下PUT请求来实现。

    http://localhost:8080/tournaments/1/registrations/3 http://localhost:8080/tournaments/2/registrations/1 http://localhost:8080/tournaments/2/registrations/2 http://localhost:8080/tournaments/2/registrations/4

    /tournaments的GET请求显示了锦标赛和他们的注册情况。这一点也可以通过H2控制台进行验证。

    锦标赛与注册信息

    锦标赛与注册信息

    如果我们通过向/tournaments/2发送DELETE请求来删除id为2的锦标赛,那么这个锦标赛和它的三个注册信息就会被删除。注册表只剩下一个注册信息。

    在删除ID为2的锦标赛

    在删除ID为2的锦标赛

孤儿记录

  1. 孤儿记录是指有外键值的记录,它指向一个不再存在的主键值。孤儿记录索引缺乏引用完整性,这意味着表中的数据处于不一致状态。

    在我们的例子中,注册记录有一个外键值tournament_id。我们可以通过破坏两者之间的关联,将注册从锦标赛中删除。在这种情况下,registration表中的记录将成为一个孤儿,因为它不再与锦标赛表中的任何条目有联系。下图显示了一个孤儿记录。

    截屏2022-05-27 20.04.27.png

    为了演示这个概念,我们在业务层中创建一个方法removeRegistration,这个方法将打破TournamentRegistration对象之间的关联。

    public void removeRegistration(Registration reg) {
        if (registrations != null)
            registrations.remove(reg);
    }
    

    我们将在TournamentController类中创建一个新的PUT映射/tournaments/{id}/remove_registrations/{registration_id}removeRegistration方法将以registraion_id为键的注册实体从使用id指定的Tournament实体中移除。

    @PutMapping("/{id}/remove_registrations/{registration_id}")
    public Tournament removeRegistration(@PathVariable int id, @PathVariable int registration_id) {
        Registration registration = registrationService.getRegistration(registration_id); 
        return service.removeRegistration(id, registration);
    }
    

    注意,控制器调用服务层方法,removeRegistration,它只是间接调用Tournament类的removeRegistration方法。

    级联类型 REMOVE 只会将删除操作级联到与父记录相关的子记录上。为了展示它是如何工作的,我们将创建与之前相同的场景(有2个锦标赛和4个注册,通过将一个注册分配给第一个锦标赛,三个注册分配给第二个锦标赛)。

    在做了上述改动后,再次运行应用程序,创建两个锦标赛和四个注册。然后如上所述将注册与两个锦标赛相关联。

    我们将通过向/tournaments/2/remove_registrations/4发送一个PUT请求,从id为2的锦标赛中删除一个注册。现在这个锦标赛还剩下两个注册信息。注意,我们没有删除注册,只是将其从锦标赛中移除。该注册记录没有与任何锦标赛相关联,是一个孤儿记录。

    数据库的当前状态反映在对/tournaments/registrations的GET请求的响应中,如下所示。

    从锦标赛2中删除一个注册后

    从锦标赛2中删除一个注册后

    接下来,通过向/tournaments/2发送DELETE请求来删除该锦标赛。删除操作被级联到注册表,与该锦标赛相关的两条记录被删除。如果我们对/registrations进行GET,我们可以看到id为4的无主记录仍然在表中。

    注册表包含一条无主记录

    注册表包含一条无主记录

orphanRemoval属性

@OneToMany注解有一个orphanRemoval属性,可以用来删除已经成为孤儿的记录。