Hibernate/JPA 的递归更新

Recursive upserts with Hibernate/JPA

Java 11,Spring,休眠 & MySQL 这里。我有一些表:

create table if not exists exam
(
    id           int(11)      not null auto_increment,
    name         varchar(100) not null,
    display_name varchar(250),
    constraint exam_pkey primary key (id),
);

create table if not exists section
(
    id           int(11)      not null auto_increment,
    exam_id      int(11)      not null,
    name         varchar(100) not null,
    display_name varchar(250),
    `order`      int(11)      not null,
    constraint section_pkey primary key (id),
    constraint section_exam_fkey foreign key (exam_id) references exam (id),
    constraint section_name_key unique (exam_id, name),
);

create table if not exists question
(
    id         int(11)      not null auto_increment,
    section_id int(11)      not null,
    name       varchar(100) not null,
    type       varchar(25)  not null,
    `order`    int(11)      not null,
    constraint question_pkey primary key (id),
    constraint question_exam_fkey foreign key (section_id) references section (id),
    constraint question_name_key unique (section_id, name),
);

JPA 实体 类 为它们建模:

@Getter
@Setter
@Entity
@Table(name = "exam")
public class Exam {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Pattern(regexp = "\w+")
    private String name;

    private String displayName;

    @OneToMany(mappedBy = "exam", fetch = FetchType.EAGER, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @OrderBy("order asc")
    private SortedSet<Section> sections = new TreeSet<>();

}

@Getter
@Setter
@Entity
@Table(name = "section")
public class Section implements Comparable<Section> {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "exam_id")
    @JsonIgnore
    private Exam exam;

    @Pattern(regexp = "\w+")
    private String name;

    private String displayName;

    @Column(name="`order`")
    private Long order;

    @OneToMany(mappedBy = "section", fetch = FetchType.EAGER, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @OrderBy("order asc")
    private SortedSet<Question> questions = new TreeSet<>();

    @Override
    public int compareTo(Section other) {
        return ObjectUtils.compare(order, other.getOrder());
    }
}

// Question is a huge, complicated entity and I don't think I need to show it for
// someone to answer this question so I am omitting it for now, but I can add it
// in here if anyone thinks it makes a difference in providing the answer

以及用于持久化它们的存储库:

@Repository
public interface ExamRepository extends JpaRepository<Exam, Long> {
    Optional<Exam> findByName(String name);
}

这是我的情况:

我有一个 ExamService 可以做到这一点:

@Service
public class ExamService {

  @Autowired
  private ExamRepository examRepository;

  public void upsertExamFromImport(Exam importedExam) {

    // for this demonstration, pretend importedExam.getName() is "sally" at runtime

    Optional<Exam> maybeExistingExam = examRepository.findByName(importedExam.getName());
    if (maybeExistingExam.isPresent()) {
      Exam existingExam = maybeExistingExam.get();

      // tell JPA/DB that the import IS the new matching exam
      importedExam.setId(existingExam.getId());
    }

    examRepository.save(importedExam);
 
  }

}

目前我的数据库确实有一个名为“sally”的考试。所以会有一场比赛。

运行此代码时出现以下异常:

org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [null]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement

java.sql.SQLIntegrityConstraintViolationException: Column 'exam_id' cannot be null

所以我认为这里发生的是:

  1. 代码发现存在与导入的考试名称匹配的现有考试(因此 maybeExistingExam 存在且非空);然后
  2. importedExam.setId(existingExam.getId()) 执行,现在导入的考试具有现有考试的 ID,但是其嵌套的 Section 实例仍然具有 null Exam 引用(exam_id).因此 Exam 被认为是“附加的”,但它的子树仍然被认为(用 JPA 的说法)是“分离的”。
  3. 当 Hibernate 继续保留导入的检查的 Section 时,它们是分离的,但是由于附加了父 Exam,因此 exam_id 应该是非空的

即使该理论不完全准确,我认为我已经足够接近了。无论如何,这里有什么解决办法? 我怎么告诉 Hibernate/JPA “嘿伙计,这个导入的考试匹配现有的,所以它需要完全(递归)覆盖数据库中的匹配项"?


更新

如果我尝试将服务代码更改为:

@Service
public class ExamService {

  @Autowired
  private ExamRepository examRepository;

  public void upsertExamFromImport(Exam importedExam) {

    // for this demonstration, pretend importedExam.getName() is "sally" at runtime

    Optional<Exam> maybeExistingExam = examRepository.findByName(importedExam.getName());
    examRepository.save(importedExam);
    if (maybeExistingExam.isPresent()) {
      Exam existingExam = maybeExistingExam.get();
      examRepository.delete(existingExam);
    }

  }

}

我在执行 examRepository.save(importedExam) 时遇到 ConstraintViolationException: Column 'exam_id' cannot be null 异常。

我无法复制您的确切异常,但经过一些修补后,我让它工作,至少在本地......

@Service
public class ExamService {

    @Autowired
    private ExamRepository examRepository;

    public void upsertExamFromImport(Exam importedExam) {
        Optional<Exam> maybeExistingExam = examRepository.findByName(importedExam.getName());
        if (maybeExistingExam.isPresent()) {
            Exam existingExam = maybeExistingExam.get();
            this.examRepository.delete(existingExam);
        }
        this.examRepository.save(importedExam);
    }
}

这就是我更改服务的方式 - 我删除了现有的考试,然后然后保存了新考试。老实说,考虑到你的唯一键是复合的,并且会有新的 id,这应该没有什么区别,但这是正确的逻辑顺序,所以最好坚持下去。

您已经在级联持久化和合并操作,所以保存应该没问题。要使删除生效,您需要为部分和问题的删除操作添加级联。

@OneToMany(mappedBy = "exam", fetch = FetchType.EAGER, cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE})
@OrderBy("order asc")
private SortedSet<Section> sections = new TreeSet<>();

在考试级联部分删除。

@OneToMany(mappedBy = "section", fetch = FetchType.EAGER, cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE})
@OrderBy("order asc")
private SortedSet<Question> questions = new TreeSet<>();

并在部分中删除级联问题。

我最终不得不手动 link 考试,它的部分,以及每个部分的问题,bi-directionally。在保存之前,这修复了一切。

示例:

exam.getSections().forEach(section -> {
    section.setExam(exam);
    section.getQuestions().forEach(question -> {
        question.setSection(section);
    });
});

examRepository.save(exam);