“批量更新从更新中返回了意外的行数” - 更新到 Micronaut 3.1+ 后

dpo*_*nen 5 hibernate jpa micronaut micronaut-data

我正在尝试升级到 Micronaut 3.2,但从 3.1 开始,数据库上的一些写入操作开始失败。我创建了一个示例项目来展示这一点: https: //github.com/dpozinen/optimistic-lock

此外,我在https://github.com/micronaut-projects/micronaut-data/issues/1230创建了一个问题

Briefly, my entities:

@MappedSuperclass
public abstract class BaseEntity {

  @Id
  @GeneratedValue(generator = "system-uuid")
  @GenericGenerator(name = "system-uuid", strategy = "uuid2")
  @Column(updatable = false, nullable = false, length = 36)
  @Type(type = "optimistic.lock.extra.UuidUserType")
  private UUID id;

  @Version
  @Column(nullable = false)
  private Integer version;
}
Run Code Online (Sandbox Code Playgroud)
public class Game extends BaseEntity {
  @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
  @ToString.Exclude
  @OrderColumn(name = "sort_index")
  private List<Question> questions = new ArrayList<>();
}
Run Code Online (Sandbox Code Playgroud)
@Inheritance(strategy = InheritanceType.JOINED)
@Table(name = "question")
public abstract class Question extends BaseEntity {
  @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
  @ToString.Exclude
  @OrderColumn(name = "sort_index")
  private List<AnswerOption> answerOptions = new ArrayList<>();
}
Run Code Online (Sandbox Code Playgroud)
public class ImageSingleChoiceQuestion extends Question {
  @OneToOne(cascade = CascadeType.ALL)
  private AnswerOption validAnswer;
}
Run Code Online (Sandbox Code Playgroud)
@Table(name = "answer_option")
public class AnswerOption extends BaseEntity {}
Run Code Online (Sandbox Code Playgroud)

Pretty basic setup. The exception occurs when I'm deleting the question from the game:

Game game = gameRepository.findById(gameId).orElseThrow()
Question question = questionRepository.findByGameAndId(gameId, questionId).orElseThrow()

game.getQuestions().remove(question)
gameRepository.saveAndFlush(game) // optional
Run Code Online (Sandbox Code Playgroud)

Expected result: the question gets detached from the game and deleted, cascading the deletion to answerOptions. This was working up until Micronaut 3.0.3, you can change the version in the sample project and the test will succeed.

Actual result:

javax.persistence.OptimisticLockException: 
Batch update returned unexpected row count from update [0];
actual row count: 0; expected: 1; 
statement executed: delete from question where id=? and version=?
Run Code Online (Sandbox Code Playgroud)

Here is the SQL that gets executed, notice the version numbers getting messed up. Any help would be greatly appreciated.

Edit 1:

Problem occurs only when applying ImageSingleChoiceQuestion#setValidAnswer with an instance that is also used in Question#setAnswerOptions

why is that the case, since this worked in Micronaut 3.0.3?

Edit 2:

Edit 3:

  • confirmed as bug and fixed in PR

Roa*_* S. 2

更新 正如 @saw303 正确指出的那样,这是 Micronaut Data 版本 <= 3.2.0 中的一个错误。该错误已修复,并于io.micronaut.data:micronaut-data-hibernate-jpa:3.2.12021 年 12 月 7 日发布(今天撰写本文时)。

https://github.com/micronaut-projects/micronaut-data/releases

我通过此更改更新了我的 PR。


我能够使用您的存储库中的代码重现该问题。Micronaut Data 变更日志显示 v3.1.0 中有相关变更。 https://github.com/micronaut-projects/micronaut-data/releases

仅当将 ImageSingleChoiceQuestion#setValidAnswer 与 Question#setAnswerOptions 中也使用的实例一起应用时,才会出现问题。

因此,当这样做时,问题就消失了 question.setValidAnswer(new AnswerOption());:说明:当在一对多和一对一中使用相同的 AnswerOption 实例时,Hibernate 会尝试在两个位置删除实例。

其原因似乎是 CascadeType.ALL。当从问题类中删除它时,测试通过。

以下解决方案通过了测试,但目前我无法解释为什么它基于生成的 SQL 起作用。这种方法需要进一步的研究。

@Getter
@Setter
@ToString
@Entity(name = "ImageSingleChoiceQuestion")
@Table(name = "image_single_choice_question")
public class ImageSingleChoiceQuestion extends Question {

    @OneToOne
    @PrimaryKeyJoinColumn
    private AnswerOption validAnswer;
}
Run Code Online (Sandbox Code Playgroud)

到目前为止,通过测试的最佳可解释解决方案是将 @OneToOne 替换为 @OneToMany,如下所示。Hibernate 将使用这种方法创建连接表image_single_choice_question_valid_answers。这当然不是最佳的。

@Getter
@Setter
@ToString
@Entity(name = "ImageSingleChoiceQuestion")
@Table(name = "image_single_choice_question")
public class ImageSingleChoiceQuestion extends Question {

    @OneToMany
    private Set<AnswerOption> validAnswers = new HashSet<>();

    public AnswerOption getValidAnswer() {
        return validAnswers.stream().findFirst().orElse(null);
    }

    public void setValidAnswer(final AnswerOption answerOption) {
        validAnswers.clear();
        validAnswers.add(answerOption);
    }
}
Run Code Online (Sandbox Code Playgroud)

公关在这里: https: //github.com/dpozinen/optimistic-lock/pull/1

我将 testcontainers 添加到您的项目中,测试类现在如下所示:

package optimistic.lock

import optimistic.lock.answeroption.AnswerOption
import optimistic.lock.image.ImageSingleChoiceQuestion
import optimistic.lock.testframework.ApplicationContextSpecification
import optimistic.lock.testframework.testcontainers.MariaDbFixture

import javax.persistence.EntityManager
import javax.persistence.EntityManagerFactory

class NewOptimisticLockSpec extends ApplicationContextSpecification
        implements MariaDbFixture {

    @Override
    Map<String, Object> getCustomConfiguration() {
        [
                "datasources.default.url"     : "jdbc:mariadb://localhost:${getMariaDbConfiguration().get("port")}/opti",
                "datasources.default.username": getMariaDbConfiguration().get("username"),
                "datasources.default.password": getMariaDbConfiguration().get("password"),
        ]
    }

    GameRepository gameRepository
    QuestionRepository questionRepository

    TestDataProvider testDataProvider
    GameTestSvc gameTestSvc

    EntityManagerFactory entityManagerFactory
    EntityManager entityManager

    @SuppressWarnings('unused')
    void setup() {
        gameRepository = applicationContext.getBean(GameRepository)
        questionRepository = applicationContext.getBean(QuestionRepository)

        testDataProvider = applicationContext.getBean(TestDataProvider)
        gameTestSvc = applicationContext.getBean(GameTestSvc)

        entityManagerFactory = applicationContext.getBean(EntityManagerFactory)
        entityManager = entityManagerFactory.createEntityManager()
    }

    void "when removing question from game, game has no longer any questions"() {
        given:
        def testGame = testDataProvider.saveTestGame()
        and:
        Game game = gameRepository.findById(testGame.getId()).orElseThrow()
        and:
        Question question = testGame.questions.first()
        assert question != null
        and:
        def validAnswer = ((ImageSingleChoiceQuestion)question).getValidAnswer()
        assert validAnswer != null

        when:
        gameTestSvc.removeQuestionFromGame(game.getId(), question.getId())

        then:
        noExceptionThrown()
        and:
        0 == entityManager.find(Game.class, game.getId()).getQuestions().size()
        and:
        null == entityManager.find(AnswerOption.class, validAnswer.getId())
    }
}
Run Code Online (Sandbox Code Playgroud)