Странное поведение версии JPA, использующей Orika для сопоставления классов. Детская версия увеличивается, но не должна

В моей модели базы данных у каждого объекта есть поле версии. У меня есть три простых объекта (клиент, долг, платеж) с отношениями @OneToMany. У клиента много долгов. Долг имеет много выплат. Я также использую Orika для сопоставления классов (dto -> entity и entity -> dto).

У меня есть 2 теста:

  1. Я полностью заполнил и сохранил ClientEntity. Затем я меняю несколько раз только одно свойство в ClientEntity и сохраняю после каждого изменения. В конце теста версия ClientEntity увеличивается. Версия дочерних сущностей равна 0 (поскольку они не изменяются).
  2. Когда я выполняю тот же тестовый пример, но используя ClientDto с отображением Orika, в конце тестового примера я вижу, что увеличивается не только версия клиента, но и дочерняя сущность, которая не была изменена.

Я не могу понять это поведение, и я не знаю, почему это происходит. Я создал простой проект с написанным тестом, в котором легко понять, что не так, см.: https://github.com/Kondziqq/jpa-versioning-problem.git.

Наиболее важные части кода:

Объект клиента:

// necessary annotations
public class ClientEntity extends BaseEntity {

@Id
@SequenceGenerator(sequenceName = "CLIENT_SEQUENCE", name = "CLIENT_SEQUENCE", allocationSize = 1)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "CLIENT_SEQUENCE")
private Long id;

private String firstName;

private String lastName;

private String language;

@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "client_id")
private Set<DebtEntity> debts;
}

Задолженность:

// necessary annotations
public class DebtEntity extends BaseEntity {

@Id
@SequenceGenerator(sequenceName = "DEBT_SEQUENCE", name = "DEBT_SEQUENCE", allocationSize = 1)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "DEBT_SEQUENCE")
private Long id;

private BigDecimal amount;

@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "debt_id")
private Set<PaymentEntity> payments;
}

Базовая сущность:

// necessary annotations
public abstract class BaseEntity implements Serializable {

@Version
private Integer version;

@CreatedDate
@Temporal(TemporalType.TIMESTAMP)
private Date creationDate;

@LastModifiedDate
@Temporal(TemporalType.TIMESTAMP)
private Date lastModificationDate;
}

Преобразователь Орика:

@Component
@Getter
public class Converter {

private MapperFacade mapper;

@PostConstruct
public void init() {

    var mapperFactory = new DefaultMapperFactory.Builder().build();
    registerMappings(mapperFactory);
    mapper = mapperFactory.getMapperFacade();
}

private void registerMappings(DefaultMapperFactory mapperFactory) {

    mapperFactory.classMap(BaseDto.class, BaseEntity.class).byDefault().register();
    mapperFactory.classMap(Client.class, ClientEntity.class).byDefault().register();
    mapperFactory.classMap(Debt.class, DebtEntity.class).byDefault().register();
    mapperFactory.classMap(Payment.class, PaymentEntity.class).byDefault().register();

    mapperFactory.classMap(BaseEntity.class, BaseDto.class).byDefault().register();
    mapperFactory.classMap(ClientEntity.class, Client.class).byDefault().register();
    mapperFactory.classMap(DebtEntity.class, Debt.class).byDefault().register();
    mapperFactory.classMap(PaymentEntity.class, Payment.class).byDefault().register();
}
}

КлиентДао:

@RequiredArgsConstructor
@Component
public class ClientDao implements ClientPersistence {

private final ClientRepository clientRepository;

private final Converter converter;

@Override
public long save(Client client) {

    var clientEntity = converter.getMapper().map(client, ClientEntity.class);
    return clientRepository.save(clientEntity).getId();
}

@Override
public Client getClientById(long id) {

    var client = clientRepository.getOne(id);
    return converter.getMapper().map(client, Client.class);
}
}

КлиентДаоТест:

// this test fails
@Test
public void shouldNotIncrementChildVersionAfterParentDtoSaved() {

    // given
    var client = Client.builder()
            .firstName("John")
            .lastName("Smith")
            .language("pl")
            .debts(Set.of(
                    Debt.builder()
                            .amount(BigDecimal.valueOf(25000))
                            .payments(Set.of(Payment.builder().amount(BigDecimal.valueOf(2500)).date(Date.from(Instant.now())).build()))
                            .build()))
            .build();

    // when
    long id = clientDao.save(client);
    var clientCopy1 = clientDao.getClientById(id);

    clientCopy1.setLanguage("en");
    id = clientDao.save(clientCopy1);
    var clientCopy2 = clientDao.getClientById(id);

    clientCopy2.setLanguage("en");
    id = clientDao.save(clientCopy2);
    var clientCopy3 = clientDao.getClientById(id);

    // then
    assertEquals(3, clientCopy3.getVersion());
    assertEquals(0, clientCopy3.getDebts().iterator().next().getVersion());
}

// this test passes
@Test
public void shouldNotIncrementChildVersionAfterParentEntitySaved() {

    // given
    var clientEntity = ClientEntity.builder()
            .firstName("John")
            .lastName("Smith")
            .language("pl")
            .debts(Set.of(
                    DebtEntity.builder()
                            .amount(BigDecimal.valueOf(25000))
                            .payments(Set.of(PaymentEntity.builder().amount(BigDecimal.valueOf(2500)).date(Date.from(Instant.now())).build()))
                            .build()))
            .build();

    // when
    var clientEntityCopy = clientRepository.save(clientEntity);

    clientEntityCopy.setLanguage("en");
    clientEntityCopy = clientRepository.save(clientEntityCopy);

    clientEntityCopy.setLanguage("ru");
    clientEntityCopy = clientRepository.save(clientEntityCopy);

    // then
    assertEquals(2, clientEntityCopy.getVersion());
    assertEquals(0, clientEntityCopy.getDebts().iterator().next().getVersion());
}

ИЗМЕНИТЬ

Наконец-то я нашел обходной путь для этой проблемы. Я изменил однонаправленное отношение @OneToMany с @JoinColumn на двунаправленное @OneToMany. Но я до сих пор не понимаю, почему это не работает с однонаправленным @OneToMany.


person Konrad    schedule 17.05.2020    source источник


Ответы (1)


Вы используете примитивный тип для BaseDto.version, который имеет значение по умолчанию 0. Поэтому, если вы сопоставляете этот объект, 0 инициализируется для версии. Если вы создадите объект напрямую, null будет инициализирован для BaseEntity.version, и Hibernate присвоит ему первое значение, которое, как я полагаю, равно 1.

person Christian Beikov    schedule 18.05.2020
comment
Я изменил его на Integer и, к сожалению, все еще не работает. Что интересно, когда я не заполняю последнюю коллекцию @OneToMany (платежи), она работает нормально. - person Konrad; 18.05.2020
comment
Вы можете переключиться на использование средств доступа к свойствам для своих сущностей, т. е. поместить аннотации JPA в геттеры, а не в поля. Затем вы можете поставить точку останова в своем сеттере и посмотреть, почему установлено поле версии и какое значение. Я думаю, это должно вас заинтересовать. - person Christian Beikov; 19.05.2020
comment
Я пробовал, но в этом случае JPA перебирает все сеттеры и устанавливает одинаковые значения. Я думаю, что проблема может быть сильно связана с вложенными отношениями @OneToMany. Как я уже сказал, когда я оставляю платежи пустыми, проблема не возникает. Я даже пытался написать свой маппер без Orika, но результат тот же. - person Konrad; 22.05.2020
comment
Наконец-то я нашел обходной путь для этой проблемы. Я изменил однонаправленное отношение OneToMany с JoinColumn на двунаправленное OneToMany. Но я до сих пор не понимаю, почему он не работает с однонаправленным OneToMany. - person Konrad; 23.05.2020