Как изменить тип Entity в JPA?

В моем конкретном случае я использую стратегию столбца дискриминатора. Это означает, что моя реализация JPA (Hibernate) создает таблицу пользователей со специальным столбцом DTYPE. Этот столбец содержит имя класса объекта. Например, таблица моих пользователей может иметь подклассы TrialUser и PayingUser. Эти имена классов будут находиться в столбце DTYPE, так что когда EntityManager загрузит объект из базы данных, он знает, какой тип класса должен быть создан.

Я пробовал два способа преобразования типов Entity, и оба чувствуют себя как грязные хаки:

  • Используйте собственный запрос, чтобы вручную сделать UPDATE в столбце, изменив его значение. Это работает для объектов, чьи ограничения свойств схожи.
  • Создайте новый объект целевого типа, вызовите вызов BeanUtils.copyProperties() для перемещения по свойствам, сохраните новый объект, затем вызовите именованный запрос, который вручную заменяет новый идентификатор старым идентификатором, чтобы все ограничения внешнего ключа сохраняются.

Проблема С# 1 заключается в том, что когда вы вручную меняете этот столбец, JPA не знает, как обновить/привязать этот Entity к контексту Persistance. Он ожидает TrialUser с идентификатором 1234, а не PayingUser с идентификатором 1234. Он выходит из строя. Здесь я мог бы, вероятно, сделать EntityManager.clear() и отсоединить все объекты/очистить Per. Контекст, но поскольку это служба bean, он уничтожит ожидающие изменения для всех пользователей системы.

Проблема С# 2 заключается в том, что при удалении TrialUser все свойства, которые вы установили в Cascade = ALL, также будут удалены. Это плохо, потому что вы пытаетесь поменять местами другой пользователь, а не удалять весь расширенный граф объектов.

Обновление 1. Проблемы №2 сделали все, кроме непригодных для меня, поэтому я отказался от попыток заставить его работать. Более элегантные хаки определенно №1, и я добился определенного прогресса в этом отношении. Ключ должен сначала получить ссылку на базовый сеанс Hibernate (если вы используете Hibernate в качестве вашей реализации JPA) и вызвать метод Session.evict(user) для удаления только одного объекта из контекста persistance. К сожалению, нет никакой чистой поддержки JPA для этого. Вот пример кода:

  // Make sure we save any pending changes
  user = saveUser(user);

  // Remove the User instance from the persistence context
  final Session session = (Session) entityManager.getDelegate();
  session.evict(user);

  // Update the DTYPE
  final String sqlString = "update user set user.DTYPE = '" + targetClass.getSimpleName() + "' where user.id = :id";
  final Query query = entityManager.createNativeQuery(sqlString);
  query.setParameter("id", user.getId());
  query.executeUpdate();

  entityManager.flush();   // *** PROBLEM HERE ***

  // Load the User with its new type
  return getUserById(userId); 

Обратите внимание на инструкцию flush(), которая выдает это исключение:

org.hibernate.PersistentObjectException: detached entity passed to persist: com.myapp.domain.Membership
at org.hibernate.event.def.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:102)
at org.hibernate.impl.SessionImpl.firePersistOnFlush(SessionImpl.java:671)
at org.hibernate.impl.SessionImpl.persistOnFlush(SessionImpl.java:663)
at org.hibernate.engine.CascadingAction$9.cascade(CascadingAction.java:346)
at org.hibernate.engine.Cascade.cascadeToOne(Cascade.java:291)
at org.hibernate.engine.Cascade.cascadeAssociation(Cascade.java:239)
at org.hibernate.engine.Cascade.cascadeProperty(Cascade.java:192)
at org.hibernate.engine.Cascade.cascadeCollectionElements(Cascade.java:319)
at org.hibernate.engine.Cascade.cascadeCollection(Cascade.java:265)
at org.hibernate.engine.Cascade.cascadeAssociation(Cascade.java:242)
at org.hibernate.engine.Cascade.cascadeProperty(Cascade.java:192)
at org.hibernate.engine.Cascade.cascade(Cascade.java:153)
at org.hibernate.event.def.AbstractFlushingEventListener.cascadeOnFlush(AbstractFlushingEventListener.java:154)
at org.hibernate.event.def.AbstractFlushingEventListener.prepareEntityFlushes(AbstractFlushingEventListener.java:145)
at org.hibernate.event.def.AbstractFlushingEventListener.flushEverythingToExecutions(AbstractFlushingEventListener.java:88)
at org.hibernate.event.def.DefaultAutoFlushEventListener.onAutoFlush(DefaultAutoFlushEventListener.java:58)
at org.hibernate.impl.SessionImpl.autoFlushIfRequired(SessionImpl.java:996)
at org.hibernate.impl.SessionImpl.executeNativeUpdate(SessionImpl.java:1185)
at org.hibernate.impl.SQLQueryImpl.executeUpdate(SQLQueryImpl.java:357)
at org.hibernate.ejb.QueryImpl.executeUpdate(QueryImpl.java:51)
at com.myapp.repository.user.JpaUserRepository.convertUserType(JpaUserRepository.java:107)

Вы можете видеть, что объект Членство, из которых Пользователь имеет набор OneToMany, вызывает некоторые проблемы. Я не знаю достаточно о том, что происходит за кулисами, чтобы взломать этот орех.

Обновление 2. Единственное, что работает до сих пор, - это изменить DTYPE, как показано в приведенном выше коде, затем вызвать entityManager.clear()

Я не совсем понимаю последствия очистки всего контекста персистентности, и мне бы хотелось, чтобы вместо Session.evict() работала над конкретным обновляемым объектом.

Ответ 1

Итак, я, наконец, вычислил рабочее решение:

Поверните EntityManager для обновления DTYPE. Это происходит главным образом из-за того, что Query.executeUpdate() должен работать внутри транзакции. Вы можете попробовать запустить его в рамках существующей транзакции, но это, вероятно, связано с тем же контекстом персистентности объекта, который вы изменяете. Это означает, что после обновления DTYPE вам нужно найти способ evict() Entity. Простым способом является вызов entityManager.clear(), но это приводит к разным побочным эффектам (читайте об этом в спецификации JPA). Лучшее решение - получить основной делегат (в моем случае, Hibernate Session) и вызвать Session.evict(пользователь). Это, вероятно, будет работать на простых графах домена, но мои были очень сложными. Я никогда не мог получить @Cascade (CascadeType.EVICT) для корректной работы с существующими аннотациями JPA, такими как @OneToOne (cascade = CascadeType.ALL). Я также пробовал вручную передать мой доменный граф Сессия, и каждый родительский Entity выселяет его дочерние элементы. Это также не срабатывало по неизвестным причинам.

Я остался в ситуации, когда работает только entityManager.clear(), но я не мог принять побочные эффекты. Затем я попытался создать отдельный блок Persistence Unit для конверсий Entity. Я предположил, что могу локализовать операцию clear() только для ПК, ответственного за конверсии. Я создал новый ПК, новый соответствующий EntityManagerFactory, новый Transaction Manager для него и вручную ввел этот менеджер транзакций в репозиторий для ручной упаковки executeUpdate() в транзакции, соответствующей соответствующему ПК. Здесь я должен сказать, что я не знаю достаточно о транзакциях, управляемых контейнером Spring/JPA, потому что он оказался кошмаром, пытаясь получить локальную/ручную транзакцию для executeUpdate(), чтобы играть хорошо, когда транзакция, управляемая контейнером, вытащилась из уровня сервиса.

В этот момент я выбросил все и создал этот класс:

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public class JdbcUserConversionRepository implements UserConversionRepository {

@Resource
private UserService userService;

private JdbcTemplate jdbcTemplate;

@Override
@SuppressWarnings("unchecked")
public User convertUserType(final User user, final Class targetClass) {

        // Update the DTYPE
        jdbcTemplate.update("update user set user.DTYPE = ? where user.id = ?", new Object[] { targetClass.getSimpleName(), user.getId() });

        // Before we try to load our converted User back into the Persistence
        // Context, we need to remove them from the PC so the EntityManager
        // doesn't try to load the cached one in the PC. Keep in mind that all
        // of the child Entities of this User will remain in the PC. This would
        // normally cause a problem when the PC is flushed, throwing a detached
        // entity exception. In this specific case, we return a new User
        // reference which replaces the old one. This means if we just evict the
        // User, then remove all references to it, the PC will not be able to
        // drill down into the children and try to persist them.
        userService.evictUser(user);

        // Reload the converted User into the Persistence Context
        return userService.getUserById(user.getId());
    }

    public void setDataSource(final DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
}

Есть две важные части этого метода, которые, я считаю, заставляют его работать:

  • Я отметил его с помощью @Transactional (распространение = распространение_NOT_SUPPORTED) который должен приостановить транзакцию, управляемую контейнером, поступающую со уровня сервиса, и разрешить преобразование на внешний компьютер.
  • Прежде чем пытаться перезагрузить преобразованный объект Entity обратно в компьютер, я вывожу старую копию, которая в настоящее время хранится на ПК с userService.evictUser(пользователем);. Код для этого - это просто экземпляр Session и вызывающий evict (пользователь). Дополнительную информацию см. В комментариях в коде, но в основном, если мы этого не сделаем, любые вызовы getUser будут пытаться вернуть кэшированную Entity на ПК, за исключением того, что она вызовет ошибку в отношении тип отличается.

Хотя мои начальные тесты прошли хорошо, это решение все еще может иметь некоторые проблемы. Я сохраню это обновление по мере их раскрытия.

Ответ 2

Вам действительно нужно представлять уровень подписки в типе пользователя? Почему бы вам не добавить тип подписки в вашей системе?

Затем отношение будет выражаться как: у Пользователя есть одна Подписка. Он также может быть смоделирован как отношение "один ко многим", если вы хотите сохранить историю подписки.

Затем, когда вы хотите изменить уровень подписки на пользователя, вместо создания экземпляра нового пользователя вы должны создать новую подписку и назначить ее пользователю.

// When you user signs up
User.setSubscription(new FreeSubscription());

// When he upgrades to a paying account
User.setSubscription(new PayingSubscription());

Действительно ли TrialUser отличается от PayingUser? Возможно нет. Вам лучше было бы обслуживать агрегацию вместо наследования.

Данные подписки могут быть сохранены в отдельной таблице (отображаются как Entity) или могут быть сохранены в таблице Users (отображается как компонент).

Ответ 3

Проблема больше связана с Java, чем с чем-либо еще. Вы не можете изменить тип времени выполнения экземпляра (во время выполнения), поэтому спящий режим не предусматривает таких сценариев (в отличие от рельсов, например).

Если вы хотите выселить пользователя из сеанса, вам придется выселять связанные объекты. У вас есть выбор:

  • Сопоставьте объект с evict = cascade (см. Session # evict javadoc)
  • Вручную выселить все связанные с ним entites (это может быть громоздким)
  • Очистите сеанс, вытеснив все объекты (конечно, вы потеряете локальный кеш сеанса)

У меня была аналогичная проблема в grails, и мое решение похоже на решение grigory. Я копирую все поля экземпляра, включая ассоциированные объекты, а затем удаляю старый объект и записываю новый. Если у вас не так много отношений, это может быть легко, но если ваша модель данных сложна, вам будет лучше использовать встроенное решение sql, которое вы описали.

Вот источник, если вы заинтересованы:

def convertToPacient = {
    withPerson(params.id) {person ->
      def pacient = new Pacient()
      pacient.properties = person.properties
      pacient.id = null
      pacient.processNumber = params.processNumber
      def ap = new Appointment(params)
      pacient.addToAppointments(ap);

      Person.withTransaction {tx ->
        if (pacient.validate() && !pacient.hasErrors()) {

          //to avoid the "Found two representations of same collection" error
          //pacient.attachments = new HashSet(person.attachments);
          //pacient.memberships = new HashSet(person.memberships);
          def groups = person?.memberships?.collect {m -> Group.get(m.group.id)}
          def attachs = []
          person.attachments.each {a ->
            def att = new Attachment()
            att.properties = a.properties
            attachs << att
          }

          //need an in in order to add the person to a group
          person.delete(flush: true)
          pacient.save(flush: true)
          groups.each {g -> pacient.addToGroup(g)};
          attachs.each {a -> pacient.addToAttachments(a)}
          //pacient.attachments.each {att -> att.id = null; att.version = null; att.person = pacient};

          if (!pacient.save()) {
            tx.setRollbackOnly()
            return
          }
        }
      }

Ответ 4

Проблема больше связана с Java чем что-либо другое. Вы не можете изменить тип экземпляра экземпляра (в время выполнения), поэтому спящий режим не предусматривают такие сценарии (в отличие от рельсов, например).

Я столкнулся с этим сообщением, потому что я (или думал, что я был) с аналогичной проблемой. Я пришел к выводу, что проблема заключается не в механизме изменения типов в технологии, а в том, что разработка типов вообще не является легкой или простой. Проблема в том, что типы довольно часто имеют разные атрибуты. Если вы не сопоставляете разные типы только поведенческих различий, неясно, что делать с дополнительной информацией. По этой причине имеет смысл переносить типы на основе построения новых объектов.

Проблема не связана с Java. он связан с теорией типов в общем и, более конкретно, с объектно-реляционным отображением. Если ваш язык поддерживает ввод текста, мне бы хотелось услышать, как вы можете автоматически изменить тип объекта во время выполнения и волшебным образом сделать что-то умное с левой информацией. Пока мы распространяем FUD: будьте осторожны с тем, с кем вы консультируетесь: Rails - это фреймворк, Ruby - это язык.

Ответ 5

Когда вы говорите о конверсии, вы, вероятно, искажаете проблему. На мой взгляд, вы пытаетесь построить экземпляр одного класса на основе экземпляра другого класса, например:

public PayingUser(TrialUser theUser) {
...
}

Затем вы можете удалить старого пробного пользователя и сохранить нового пользователя.