从零开始 Spring Boot 55:JPA 中的主键和唯一索引

从零开始 Spring Boot 55:JPA 中的主键和唯一索引

spring boot

图源:简书 (jianshu.com)

在表结构设计中,我们用主键来确保一条数据在表中的唯一性。类似的,可以用唯一索引确保某列数据都是唯一的。如果需要限制多个列的唯一性,还可以使用联合唯一索引。

本文将探讨如何在 JPA (Hibernate)中使用主键和唯一索引。

主键

通过之前的文章,我们已经很熟悉怎么在实体中设置一个自增主键了,这也是表结构设计中最常见的方式。但严格来说,对于关系型数据库这并不是必须的。实际上这是日常开发应用中的一种“改良”,使用一个唯一的自增索引可以降低表结构设计的复杂度,并且在利用 Id 查找和定位数据上有一定帮助。

当然,我们可以在必要的时候不使用自增主键,转而使用更传统的主键或联合主键。

我们先看不使用自增主键的单一主键要怎么设置:

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "USER_STUDENT")
public class Student {
    @Id
    @EqualsAndHashCode.Exclude
    @Length(min = 10, max = 10)
    @NotNull
    private String number;

    @NotNull
    @Length(min = 5, max = 50)
    private String name;

    @NotNull
    @Setter(AccessLevel.NONE)
    private LocalDate birthDay;

    @Transient
    @Setter(AccessLevel.NONE)
    @Getter(AccessLevel.NONE)
    @EqualsAndHashCode.Exclude
    @Nullable
    private Integer age;

    @NotNull
    @Enumerated(EnumType.ORDINAL)
    private Gender gender;
	// ...
}

注意,这里不再是使用自增的id作为主键,而是使用了number(学号)作为主键,具体的学号生成规则我们可以自行定义,只要确保其唯一性即可。

示例中使用 Hibernate Validation 注解(如@Size)来约束实体类的属性格式(比如字符串长度),这样做的好处是同时可以影响到 DDL。这一点在上篇文章中有过讨论。

Hibernate 自动生成的 DDL:

CREATE TABLE `user_student` (
  `number` varchar(10) COLLATE utf8mb4_general_ci NOT NULL,
  `birth_day` date NOT NULL,
  `gender` tinyint NOT NULL,
  `name` varchar(50) COLLATE utf8mb4_general_ci NOT NULL,
  PRIMARY KEY (`number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

此时不会生成xxx_seq,这是很自然的事情。

测试用例比较简单,这里不再赘述,感兴趣的可以查看完整代码

联合主键

对于某些“关系表”,会需要使用多个字段作为主键,即联合主键。

下面看一个实际示例,这里有两个实体,分别表示学生和班级:

// ...
@Table(name = "USER_STUDENT")
public class Student {
    @Id
    @EqualsAndHashCode.Exclude
    @Length(min = 10, max = 10)
    @NotNull
    private String number;
	// ...
}

// ...
@Table(name = "user_class")
public class ClassRoom {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    /**
     * 班级名称,如一年级二班
     */
    @NotNull
    @Length(min = 5, max = 25)
    @Column(unique = true)
    private String name;
}

Student实体的主键是numberClassRoom的主键是id

假设我们需要添加了一个实体表示班级和学生的关系,并在其中保存某些附加在这个关系上的信息(比如座位号):

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "user_student_class")
public class StudentClass {
    /**
     * 班级 id
     */
    private Long classRoomId;
    /**
     * 学号
     */
    private String studentNumber;

    /**
     * 座位号
     */
    private int seatNumber;
}

显然,这个实体类要使用classRoomIdstudentNumber两个字段作为联合主键。

首先,需要为联合主键创建一个单独的类并包含相应的字段:

@NoArgsConstructor
@EqualsAndHashCode
public class StudentClassId implements Serializable {
    private Long classRoomId;
    private String studentNumber;
}

作为联合主键的类必须满足以下要求:

  • 是可序列化的(Serializable
  • 包含一个默认构造器(无参构造器)
  • 实现了equalshashCode方法

StudentClass实体中使用这个类:

// ...
@IdClass(StudentClassId.class)
public class StudentClass {
    @Id
    private Long classRoomId;
    @Id
    private String studentNumber;
	// ...
}

需要使用@IdClass注解指定可以作为联合主键的类定义,以及对实体类中作为联合主键的属性使用@Id注解。

要注意的是,并没有任何手段映射实体类StudentClass中作为联合主键的字段和StudentClassId类中的相应字段,Hibernate 仅仅使用属性名称和类型进行映射(匹配),所以这里必须保证对应的属性名称和类型完全一致。

可以利用联合主键构成的关系表进行联表查询,比如:

@SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection")
@Service
public class ClassRoomService {
    @Autowired
    private StudentClassRepository studentClassRepository;
    @Autowired
    private StudentRepository studentRepository;
    @Autowired
    private ClassRoomRepository classRoomRepository;

    /**
     * 获取指定班级的所有学生
     *
     * @param classId 班级id
     * @return
     */
    public List<Student> findStudentsByClassId(long classId) {
        var scList = studentClassRepository.findAll(Example.of(StudentClass.builder()
                .classRoomId(classId)
                .build()));
        var studentNumbers = scList.stream().map(sc -> sc.getStudentNumber()).collect(Collectors.toSet());
        return studentRepository.findAllById(studentNumbers);
    }

    /**
     * 根据学号查询其所属班级
     *
     * @param studentNumber
     * @return
     */
    public ClassRoom findClassRoomByStudentNumber(String studentNumber) {
        var sc = studentClassRepository.findOne(Example.of(StudentClass.builder()
                .studentNumber(studentNumber)
                .build())).get();
        return classRoomRepository.findById(sc.getClassRoomId()).get();
    }
}

@EmbeddedId

除了上述方式外,还有一种方式同样可以实现联合主键。

我们在之前的文章中介绍过@Embeddable@Embedded的作用,实际上可以用类似的方式实现联合主键。

下面用一个示例说明,在这个示例中将创建一个教师(Teacher)实体,并由教师-班级实体(TeacherClass)表示教师和班级的关联关系(多对多)。

先定义一个教师实体:

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "user_teacher_class")
public class TeacherClass {
    @EmbeddedId
    private TeacherClassId teacherClassId;
    /**
     * 是否为班主任
     */
    @NotNull
    private Boolean classLeader = false;
}

定义用于教师-班级实体的联合主键类:

@Getter
@Embeddable
@EqualsAndHashCode
@NoArgsConstructor
@Builder
@AllArgsConstructor
public class TeacherClassId implements Serializable {
    private long teacherId;
    private long classRoomId;
}

和之前的要求类似,这里同样要求这个类型是可序列化、拥有默认构造器以及实现equalshashCode方法的。

定义教师-班级实体:

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "user_teacher_class")
public class TeacherClass {
    @EmbeddedId
    private TeacherClassId teacherClassId;
    /**
     * 是否为班主任
     */
    @NotNull
    private Boolean classLeader = false;
}

这里使用@EmbeddedId以“嵌入”的方式添加了一个联合主键属性,这个属性实际上会映射到 DDL 中的相应字段:

CREATE TABLE `user_teacher_class` (
  `class_room_id` bigint NOT NULL,
  `teacher_id` bigint NOT NULL,
  `class_leader` bit(1) NOT NULL,
  PRIMARY KEY (`class_room_id`,`teacher_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

相对来说,个人认为这种实现方式更简洁一些。

唯一索引

如果要确保某一列数据是唯一的,除了将其设置为主键外,还可以使用唯一索引。

在单个字段上添加唯一索引很简单:

// ...
@Entity
@Table(name = "user_teacher")
public class Teacher {
    // ...
    @Column(unique = true)
    private String name;
}

在这个示例中,通过使用@Column(unique = true)可以确保user_teacher表的name字段是唯一的。

Hibernate 自动生成的 DDL 证明了这一点:

CREATE TABLE `user_teacher` (
  `id` bigint NOT NULL,
  `name` varchar(25) COLLATE utf8mb4_general_ci NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `UK_de5s198srx0o9jrgfxmsd271e` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

联合唯一索引

如果要对一张表中的多个列的进行限制,让其组合后具备唯一性,就需要使用联合唯一索引。

这种情况并不常见,一般在关系表使用自增主键的情况下使用,以确保“真实”主键的唯一性。

下面看一个示例:

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "user_club")
public class Club {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @NotBlank
    @NotNull
    @Length(max = 50)
    private String name;
}

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "user_student_club")
public class StudentClub {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String studentNumber;
    private Long clubId;
}

这个示例中Club表示学生社团实体,StudentClub表示学生社团和学生的对应关系。

值得注意的是,在关系实体中并没有使用联合主键,依然使用单一的自增主键。这样做有一个问题,我们难以确保user_student_club表在执行完数据库插入后其字段studentNumberclubId具备唯一的对应关系。因为这两个字段实际上起着“逻辑主键”的作用,它们联合起来确定了唯一的“学生-社团”对应关系,我们必须要确保其在表数据中是唯一的。

可以使用联合唯一索引来解决这个问题:

// ...
@Table(name = "user_student_club", uniqueConstraints = {
        @UniqueConstraint(name = "student_club_idx", columnNames = {"studentNumber", "clubId"})
})
public class StudentClub {
	// ...
}

这里用@UniqueConstraint定义了一个“唯一约束”,并包含在@TableuniqueConstraints属性中。

换言之,可以通过uniqueConstraints属性添加多个“唯一约束”。

这反映在 Hibernate 生成的 DDL 中就是联合唯一索引:

CREATE TABLE `user_student_club` (
  `id` bigint NOT NULL,
  `club_id` bigint DEFAULT NULL,
  `student_number` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `student_club_idx` (`student_number`,`club_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

创建唯一索引除了可以约束唯一性外,额外的好处是作为索引,可以优化查询速度。

最后可以通过测试用例证明唯一索引生效:

@Test
void testAddStudentClub(@Autowired List<Student> students,
                        @Autowired List<Club> clubs,
                        @Autowired Student student,
                        @Autowired Club club) {
    students.forEach(s -> {
        clubs.forEach(c -> {
            studentClubRepository.save(StudentClub.builder()
                                       .clubId(c.getId())
                                       .studentNumber(s.getNumber())
                                       .build());
        });
    });
    //单独插入一条重复数据,这里应当报错
    Assertions.assertThrows(DataIntegrityViolationException.class, () -> {
        studentClubRepository.save(StudentClub.builder()
                                   .studentNumber(student.getNumber())
                                   .clubId(club.getId())
                                   .build());
    });
}

The End,谢谢阅读。

可以从这里获取本文的完整示例代码。

参考资料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值