使用 Hibernate/JPA 进行递归更新插入

发布于 2025-01-10 01:52:35 字数 6255 浏览 0 评论 0原文

Java 11、Spring、Hibernate 和 Java 11 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);
}

这是我的情况:

  • 在任何给定的时间点,数据库中都会有 0+ exam 记录
    • 显然,如果其中有 0 个 exam 实体,那么它也不会包含任何 sectionquestion 实体,因此,所有 3 个表都将完全为空(在本例中)
    • 或者,可能有数百个考试记录,每个记录都有自己的多个部分,每个部分都有多个自己的问题 记录(例如表中充满了数据)
  • 我的服务器将从另一个源(不是这个 MySQL DB),让我们将这些称为“导入的考试
    • 这确实不重要,但基本上文件将通过 FTP 传输到文件夹中,并且异步作业将这些文件反序列化为 Exam 实例
  • 如果这些导入的 Exam 之一具有name 值与 MySQL DB 中的任何 Exam 实体匹配,我想要导入的 Exam 及其整个子树对象图(其所有部分) ,以及每个部分的问题)以完全覆盖匹配的数据库 Exam 及其子树/对象图
    • 例如,如果数据库有一个名为“sally”的考试,它有 1 个部分,该部分有 4 个问题,然后导入的考试也有一个名称“sally”,我希望它完全递归地完全覆盖“DB sally”考试以及该考试的所有部分和问题
    • 发生此覆盖时,属于“旧”(现有)考试的所有部分和问题都将被删除,并被新导入考试的部分和问题覆盖/替换
  • 但如果导入考试的名称​​不与数据库中的任何考试名称匹配,我希望将其作为全新的考试< /code> 实例,其整个子树/对象图持久化到各自的表中

我有一个 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< /code> 考试参考 (exam_id)。因此,Exam 被视为“附加”,但其子树仍被视为(用 JPA 术语)“分离”。
  3. 当 Hibernate 保留导入考试的 Section 时,它们会被分离,但由于父 Exam 已附加,因此 exam_id 预计为非-null

即使这个理论不完全准确,我想我已经足够接近了。无论如何,这里的修复是什么?我如何告诉 Hibernate/JPA“嘿朋友,这个导入的考试与现有的匹配,所以它需要完全(递归地)覆盖数据库中的匹配< /em>”?


更新

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

@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' 不能为 null 异常。

Java 11, Spring, Hibernate & MySQL here. I have some tables:

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),
);

And the JPA entity classes modeling them:

@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

And the repositories used for persisting them:

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

Here is my situation:

  • at any given point in time, the database will have 0+ exam records in it
    • obviously if it has 0 exam entities in it, it will also not have any section or question entities in it either, and thus, all 3 tables would be completely empty (in this case)
    • or, there could be hundreds of exam records, each with their own multiple sections, and each section with a multitude of its own question records (e.g. the tables are full of data)
  • my server will be receiving a new Exam instance (with its own "subtree" of Section and Question instances) from another source (not this MySQL DB), let us refer to these a "Imported Exams"
    • it really shouldn't matter but basically files will be FTP'd into a folder and an asynchronous job deserializes these files into Exam instances
  • if one of these Imported Exams has a name value that matches any of the Exam entities in the MySQL DB, I want the imported Exam and its entire subtree object graph (all its sections, and each section's questions) to completely overwrite the matching DB Exam and its subtree/object graph
    • so for example, if the DB has an Exam named "sally" and it has 1 section and that section has 4 questions, and then an imported Exam also has a name of "sally", I want it to completely overwrite the "DB sally" exam, and all of that exam's sections and questions, completely and recursively
    • when this overwrite happens, all the sections and questions belonging to the "old" (existing) Exam are deleted and overwritten/replaced by the sections and questions of the new imported exam
  • but if the import Exam's name doesn't match any exam names in the DB, I want it inserted as a brand new Exam instance, with its whole entire subtree/object graph persisted to their respective tables

I have an ExamService for doing this:

@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);
 
  }

}

Currently my database does have an exam named "sally". So there will be a match.

When this code runs I get the following exception:

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

So what I think is happening here is:

  1. The code sees that there is an existing exam that matches the imported exam's name (hence maybeExistingExam is present and non-null); then
  2. importedExam.setId(existingExam.getId()) executes and now the imported exam has the existing exam's ID, however its nested Section instances still have a null Exam reference (exam_id). Hence the Exam is considered "attached" yet its subtree is still considered (in JPA parlance) to be "detached".
  3. When Hibernate goes to persist the imported exam's Sections, they are detached but since the parent Exam is attached, the exam_id is expected to be non-null

Even if that theory isn't completely accurate, I think I'm close enough. Regardless, what's the fix here? How do I tell Hibernate/JPA "hey pal, this imported exam matches an existing, so it needs to completely (recursively) overwrite the match in the DB"?


Update

If I try changing the service code to:

@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);
    }

  }

}

I get a ConstraintViolationException: Column 'exam_id' cannot be null exception when it executes examRepository.save(importedExam).

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(2

若相惜即相离 2025-01-17 01:52:35

我无法复制您的确切例外情况,但经过一番修改后,我让它工作起来,至少在本地...

@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<>();

并分段删除级联问题。

I wasn't able to replicate your exact exceptions, but after tinkering a bit, i made it work, locally at least...

@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);
    }
}

That's how i changed the service - i delete the existing exam, and then save the new one. To be honest that should not make difference, considering that your unique keys are composite and there will be new ids, but this the correct logical order, so it's better to stick to it.

You are already cascading persist and merge operations, so save should be ok. For delete to work you would need to add cascading for removal operations both for sections and questions.

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

In exams cascade sections removal.

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

And cascade questions removal in sections.

动次打次papapa 2025-01-17 01:52:35

我最终不得不手动将考试、其各个部分以及每个部分的问题双向链接在一起。在保存之前,一切都解决了。

例子:

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

examRepository.save(exam);

I ended up having to manually link the exam, its sections, and each section's questions together, bi-directionally. Prior to the save, and that fixed everything.

Example:

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

examRepository.save(exam);
~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文