How to change Entity type in JPA?

2020-02-05 03:49发布

In my specific case, I am making use of a discriminator column strategy. This means that my JPA implementation (Hibernate) creates a users table with a special DTYPE column. This column contains the class name of the entity. For example, my users table can have subclasses of TrialUser and PayingUser. These class names would be in the DTYPE column so that when the EntityManager loads the entity from the database, it knows which type of class to instantiate.

I've tried two ways of converting Entity types and both feel like dirty hacks:

  1. Use a native query to manually do an UPDATE on the column, changing its value. This works for entities whose property constraints are similar.
  2. Create a new entity of the target type, do a BeanUtils.copyProperties() call to move over the properties, save the new entity, then call a named query which manually replaces the new Id with the old Id so that all the foreign key constraints are maintained.

The problem with #1 is that when you manually change this column, JPA doesn't know how to refresh/reattach this Entity to the Persistance Context. It expects a TrialUser with Id 1234, not a PayingUser with Id 1234. It fails out. Here I could probably do an EntityManager.clear() and detach all Entities/clear the Per. Context, but since this is a Service bean, it would wipe pending changes for all users of the system.

The problem with #2 is that when you delete the TrialUser all of the properties you have set to Cascade=ALL will be deleted as well. This is bad because you're only trying to swap in a different User, not delete all the extended object graph.

Update 1: The problems of #2 have made it all but unusable for me, so I've given up on trying to get it to work. The more elegant of the hacks is definitely #1, and I have made some progress in this respect. The key is to first get a reference to the underlying Hibernate Session (if you're using Hibernate as your JPA implementation) and call the Session.evict(user) method to remove only that single object from your persistance context. Unfortunitely there is no pure JPA support for this. Here is some sample code:

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

Notice the manual flush() which throws this exception:

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)

You can see that the Membership entity, of which User has a OneToMany Set, is causing some problems. I don't know enough about what's going on behind the scenes to crack this nut.

Update 2: The only thing that works so far is to change DTYPE as shown in the above code, then call entityManager.clear()

I don't completely understand the ramifications of clearing the entire persistence context, and I would have liked to get Session.evict() working on the particular Entity being updated instead.

5条回答
虎瘦雄心在
2楼-- · 2020-02-05 04:08

The problem is more related to Java than anything else. You can't change a runtime type of an instance (at runtime), so hibernate does not provide for this kind of scenarios (unlike rails for example).

If you want to evict the user from the session, you'll have to evict associated entities. You have some choices:

  • Map the entity with evict=cascade (see the Session#evict javadoc)
  • Manually evict all associated entites (this can be cumbersome)
  • Clear the session, thus evicting all entities (of course, you'll lose the local session cache)

I've had a similar problem in grails, and my solution is similar to grigory's solution. I copy all instance fields, including the associated entities, then delete the old entity and write the new one. If you don't have that many relations this can be easy, but if your data model is complex you'll be better off using the native sql solution you described.

Here is the source, in case you're interested:

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
          }
        }
      }
查看更多
够拽才男人
3楼-- · 2020-02-05 04:25

Do you really need to represent the subscription level in the User type? Why don't you add a Subscription type in your system?

The relation would then be expressed as: a User has one Subscription. It could also be modeled as a one-to-many relationship if you wish to keep an history of subscriptions.

Then when you want to change the Subscription level of a User, instead of instantiating a new User you would create a new Subscription and assign it to a user.

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

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

Is a TrialUser really that different from a PayingUser? Probably not. You would be better served with aggregation instead of inheritance.

The Subscription data can be stored in a separate table (mapped as an Entity) or it can be stored inside the Users table (mapped as a Component).

查看更多
4楼-- · 2020-02-05 04:25

The problem is more related to Java than anything else. You can't change a runtime type of an instance (at runtime), so hibernate does not provide for this kind of scenarios (unlike rails for example).

I encountered this post because I am (or thought I was) having a similar problem. I've come to believe the problem is not with mechanics of changing types in the technology, but that evolving the types in general is not at all easy or straightforward. The issue is that types quite often have varying attributes. Unless you are mapping to different types only for behavioral differences, it isn't clear what to do with the additional information. For this reason, it makes sense to migrate types based on construction of new objects.

The problem is not 'related to Java'. it is related to type theory in general and more specifically object-relational mapping here. If your language supports typing, I'd love to hear how you can automatically change the type of an object at runtime and magically do something intelligent with the left over information. As long as we are spreading FUD: beware who you take advice from: Rails is a framework, Ruby is a language.

查看更多
5楼-- · 2020-02-05 04:28

When you say conversion you probably misrepresent the problem. What you really is trying to do in my opinion is to construct instance of one class based on an instance of the other class, for example:

public PayingUser(TrialUser theUser) {
...
}

Then you can delete old trial user and persist new paying user.

查看更多
Root(大扎)
6楼-- · 2020-02-05 04:31

So I finally figured out a working solution:

Ditch the EntityManager for updating DTYPE. This is mainly because Query.executeUpdate() must run within a transaction. You can try running it within the existing transaction, but that is probably tied to the same persistence context of the Entity you're modifying. What this means is that after you update DTYPE you have to find a way to evict() the Entity. The easy way is to call entityManager.clear() but this results in all sorts of side effects (read about it in the JPA spec). The better solution is to get the underlying delegate (in my case, a Hibernate Session) and call Session.evict(user). This will probably work on simple domain graphs, but mine were very complex. I was never able to get @Cascade(CascadeType.EVICT) to work correctly with my existing JPA annotations, like @OneToOne(cascade = CascadeType.ALL). I also tried manually passing my domain graph a Session and having each parent Entity evict its children. This also didn't work for unknown reasons.

I was left in a situation where only entityManager.clear() would work, but I couldn't accept the side effects. I then tried creating a separate Persistence Unit specifically for Entity conversions. I figured I could localize the clear() operation to only that PC in charge of conversions. I set up a new PC, a new corresponding EntityManagerFactory, a new Transaction Manager for it, and manually injecting this transaction manager into the Repository for manual wrapping of the executeUpdate() in a transaction corresponding to the proper PC. Here I have to say that I don't know enough about Spring/JPA container managed transactions, because it ended up being a nightmare trying to get the local/manual transaction for executeUpdate() to play nicely with the container managed transaction getting pulled in from the Service layer.

At this point I threw out everything and created this class:

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

There are two important parts of this method which I believe make it work:

  1. I've marked it with @Transactional(propagation = Propagation.NOT_SUPPORTED) which should suspend the container managed transaction coming in from the Service layer and allow the conversion to take place external of the PC.
  2. Before trying to reload the converted Entity back into the PC, I evict the old copy currently stored in the PC with userService.evictUser(user);. The code for this is simply getting a Session instance and calling evict(user). See the comments in code for more details, but basically if we don't do this any calls to getUser will try to return the cached Entity still in the PC, except that it will throw an error about the type being different.

Though my initial tests have gone well, this solution may still have some problems. I will keep this updated as they are uncovered.

查看更多
登录 后发表回答