简体   繁体   中英

JPA double relation with the same Entity

I have these Entities:

@Entity
public class Content extends AbstractEntity
{
    @NotNull
    @OneToOne(optional = false)
    @JoinColumn(name = "CURRENT_CONTENT_REVISION_ID")
    private ContentRevision current;

    @OneToMany(mappedBy = "content", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<ContentRevision> revisionList = new ArrayList<>();
}

@Entity
public class ContentRevision extends AbstractEntity
{
    @NotNull
    @ManyToOne(optional = false)
    @JoinColumn(name = "CONTENT_ID")
    private Content content;

    @Column(name = "TEXT_DATA")
    private String textData;

    @Temporal(TIMESTAMP)
    @Column(name = "REG_DATE")
    private Date registrationDate;
}

and this is the db mapping:

CONTENT
+-----------------------------+--------------+------+-----+---------+----------------+
| Field                       | Type         | Null | Key | Default | Extra          |
+-----------------------------+--------------+------+-----+---------+----------------+
| ID                          | bigint(20)   | NO   | PRI | NULL    | auto_increment |
| CURRENT_CONTENT_REVISION_ID | bigint(20)   | NO   | MUL | NULL    |                |
+-----------------------------+--------------+------+-----+---------+----------------+

CONTENT_REVISION
+-----------------------------+--------------+------+-----+---------+----------------+
| Field                       | Type         | Null | Key | Default | Extra          |
+-----------------------------+--------------+------+-----+---------+----------------+
| ID                          | bigint(20)   | NO   | PRI | NULL    | auto_increment |
| REG_DATE                    | datetime     | YES  |     | NULL    |                |
| TEXT_DATA                   | longtext     | YES  |     | NULL    |                |
| CONTENT_ID                  | bigint(20)   | NO   | MUL | NULL    |                |
+-----------------------------+--------------+------+-----+---------+----------------+

I have also these requirements:

  1. Content.current is always a member of Content.revisionList (think about Content.current as a "pointer").
  2. Users can add a new ContentRevision to an existing Content
  3. Users can add a new Content with an initial ContentRevision (cascade persist)
  4. Users can change Content.current (move the "pointer")
  5. Users can modify Content.current.textData , but saves Content (cascade merge)
  6. Users can delete ContentRevision
  7. Users can delete Content (cascade remove to ContentRevision )

:

  1. Is this the best approach? Any best practice?
  2. Is it safe to cascade merge when the same entity is referenced twice?
    ( Content.current is also Content.revisionList[i] )
  3. Are Content.current and Content.revisionList[i] the same instance?
    ( Content.current == Content.revisionList[i] ?)

Thanks


I'm very grateful for your effort. 我非常感谢你的努力。 Thank you, really.

However, there's a problematic (missing) case from your tests: when you run it inside a container using CMT:

@RunWith(Arquillian.class)
public class ArquillianTest
{
    @PersistenceContext
    private EntityManager em;

    @Resource
    private UserTransaction utx;

    @Deployment
    public static WebArchive createDeployment()
    {
        // Create deploy file
        WebArchive war = ShrinkWrap.create(WebArchive.class, "test.war");
        war.addPackages(...);
        war.addAsResource("persistence-arquillian.xml", "META-INF/persistence.xml");
        war.addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml");

        // Show the deploy structure
        System.out.println(war.toString(true));

        return war;
    }

    @Test
    public void testDetached()
    {
        // find a document
        Document doc = em.find(Document.class, 1L);
        System.out.println("doc: " + doc);  // Document@1342067286

        // get first content
        Content content = doc.getContentList().stream().findFirst().get();
        System.out.println("content: " + content);  // Content@511063871

        // get current revision
        ContentRevision currentRevision = content.getCurrentRevision();
        System.out.println("currentRevision: " + currentRevision);  // ContentRevision@1777954561

        // get last revision
        ContentRevision lastRevision = content.getRevisionList().stream().reduce((prev, curr) -> curr).get();
        System.out.println("lastRevision: " + lastRevision); // ContentRevision@430639650

        // test equality
        boolean equals = Objects.equals(currentRevision, lastRevision);
        System.out.println("1. equals? " + equals);  // true

        // test identity
        boolean same = currentRevision == lastRevision;
        System.out.println("1. same? " + same);  // false!!!!!!!!!!

        // since they are not the same, the rest makes little sense...

        // make it dirty
        currentRevision.setTextData("CHANGED " + System.currentTimeMillis());

        // perform merge in CMT transaction
        utx.begin();
        doc = em.merge(doc);
        utx.commit();  // --> ERROR!!!

        // get first content
        content = doc.getContentList().stream().findFirst().get();

        // get current revision
        currentRevision = content.getCurrentRevision();
        System.out.println("currentRevision: " + currentRevision);

        // get last revision
        lastRevision = content.getRevisionList().stream().reduce((prev, curr) -> curr).get();
        System.out.println("lastRevision: " + lastRevision);

        // test equality
        equals = Objects.equals(currentRevision, lastRevision);
        System.out.println("2. equals? " + equals);

        // test identity
        same = currentRevision == lastRevision;
        System.out.println("2. same? " + same);
    }
}

since they are not the same:

  1. if I enable cascading on both properties, an Exception is thrown

     java.lang.IllegalStateException: Multiple representations of the same entity [it.shape.edea2.jpa.ContentRevision#1] are being merged. Detached: [ContentRevision@430639650]; Detached: [ContentRevision@1777954561] 
  2. if I disable cascade on current, the change get lost.

the strange thing is that running this test outside the container results in successful execution.

Maybe it's lazy loading (hibernate.enable_lazy_load_no_trans=true), maybe something else, but it's definitely .

I wonder if there's a way to get the same instance.

Is it safe to cascade merge when the same entity is referenced twice?

Yes. If you manage an instance of Content , then it's Content.revisionList and Content.current are managed as well. Changes in any of those will be persisted when flushing the entity manager. You don't have to call EntityManager.merge(...) manually, unless you're dealing with transient objects that need to be merged.

If you create a new ContentRevision , then call persist(...) instead of merge(...) with that new instance and make sure it has a managed reference to the parent Content , also add it to the content's list.

Are Content.current and Content.revisionList[i] the same instance?

Yes, should be. Test it to be sure.

Content.current is always a member of Content.revisionList (think about Content.current as a "pointer").

You could do that check in in SQL with a check constraint; or in Java, although you'd have to be sure the revisionList is fetched. By default it's lazy fetched, meaning Hibernate will run another query for this list if you access the getRevisionList() method. And for that you need a running transaction, otherwise you'll be getting a LazyInitializationException .

You could instead load the list eagerly , if that's what you want. Or you could define a entity graph to be able to support both strategies in different queries.

Users can modify Content.current.textData, but saves Content (cascade merge)

See my first paragraph above, Hibernate should save changes on any managed entity automatically.

Users can delete ContentRevision

if (content.getRevisionList().remove(revision))
    entityManager.remove(revision);

if (revision.equals(content.getCurrentRevision())
    content.setCurrentRevision(/* to something else */);

Users can delete Content (cascade remove to ContentRevision)

Here I'd prefer to ensure that in the database schema, for instance

FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE;

UPDATE

As requested, I wrote a test. See this gist for the implementations of Content and ContentRevision I used.

I had to make one important change though: Content.current cannot really be @NotNull , especially not the DB field, because if it were, then we couldn't persist a content and revision at the same time, since both have no ID yet. Hence the field must be allowed to be NULL initially.

As a workaround I added the following method to Content :

@Transient // ignored in JPA
@AssertTrue // javax.validation
public boolean isCurrentRevisionInList() {
    return current != null && getRevisionList().contains(current);
}

Here the validator ensures that the there is always a non-null current revision and that it is contained in the revision list.

Now here are my tests.

This one proves that the references are the same (Question 3) and that it is enough to persist content where current and revisionList[0] is referencing the same instance (question 2):

@Test @InSequence(0)
public void shouldCreateContentAndRevision() throws Exception {

    // create java objects, unmanaged:
    Content content = Content.create("My first test");

    assertNotNull("content should have current revision", content.getCurrent());
    assertSame("content should be same as revision's parent", content, content.getCurrent().getContent());
    assertEquals("content should have 1 revision", 1, content.getRevisionList().size());
    assertSame("the list should contain same reference", content.getCurrent(), content.getRevisionList().get(0));

    // persist the content, along with the revision:
    transaction.begin();
    entityManager.joinTransaction();
    entityManager.persist(content);
    transaction.commit();

    // verify:
    assertEquals("content should have ID 1", Long.valueOf(1), content.getId());
    assertEquals("content should have one revision", 1, content.getRevisionList().size());
    assertNotNull("content should have current revision", content.getCurrent());
    assertEquals("revision should have ID 1", Long.valueOf(1), content.getCurrent().getId());
    assertSame("current revision should be same reference", content.getCurrent(), content.getRevisionList().get(0));
}

The next ensures that it's still true after loading the entity:

@Test @InSequence(1)
public void shouldLoadContentAndRevision() throws Exception {
    Content content = entityManager.find(Content.class, Long.valueOf(1));
    assertNotNull("should have found content #1", content);

    // same checks as before:
    assertNotNull("content should have current revision", content.getCurrent());
    assertSame("content should be same as revision's parent", content, content.getCurrent().getContent());
    assertEquals("content should have 1 revision", 1, content.getRevisionList().size());
    assertSame("the list should contain same reference", content.getCurrent(), content.getRevisionList().get(0));
}

And even when updating it:

@Test @InSequence(2)
public void shouldAddAnotherRevision() throws Exception {
    transaction.begin();
    entityManager.joinTransaction();
    Content content = entityManager.find(Content.class, Long.valueOf(1));
    ContentRevision revision = content.addRevision("My second revision");
    entityManager.persist(revision);
    content.setCurrent(revision);
    transaction.commit();

    // re-load and validate:
    content = entityManager.find(Content.class, Long.valueOf(1));

    // same checks as before:
    assertNotNull("content should have current revision", content.getCurrent());
    assertSame("content should be same as revision's parent", content, content.getCurrent().getContent());
    assertEquals("content should have 2 revisions", 2, content.getRevisionList().size());
    assertSame("the list should contain same reference", content.getCurrent(), content.getRevisionList().get(1));
}
SELECT * FROM content;
 id | version | current_content_revision_id 
----+---------+-----------------------------
  1 |       2 |                           2

UPDATE 2

It was hard to reproduce that situation on my machine, but I got it to work. Here is what I've done so far:

I changed all @OneToMany relations to use lazy fetching (the default) and rerun the following test case:

@Test @InSequence(3)
public void shouldChangeCurrentRevision() throws Exception {
    transaction.begin();
    entityManager.joinTransaction();
    Document document = entityManager.find(Document.class, Long.valueOf(1));
    assertNotNull(document);
    assertEquals(1, document.getContentList().size());
    Content content = document.getContentList().get(0);
    assertNotNull(content);
    ContentRevision revision = content.getCurrent();
    assertNotNull(revision);
    assertEquals(2, content.getRevisionList().size());
    assertSame(revision, content.getRevisionList().get(1));
    revision.setTextData("CHANGED");
    document = entityManager.merge(document);
    content = document.getContentList().get(0);
    revision = content.getCurrent();
    assertSame(revision, content.getRevisionList().get(1));
    assertEquals("CHANGED", revision.getTextData());
    transaction.commit();
}

The test passed with lazy fetching. Note that lazy fetching requires it to be executed within a transaction.

For some reason the content revision instance you're editing is not the same as the one in the one-to-many list. To reproduce that I've modified my test as follows:

@Test @InSequence(4)
public void shouldChangeCurrentRevision2() throws Exception {
    transaction.begin();
    Document document = entityManager.find(Document.class, Long.valueOf(1));
    assertNotNull(document);
    assertEquals(1, document.getContentList().size());
    Content content = document.getContentList().get(0);
    assertNotNull(content);
    ContentRevision revision = content.getCurrent();
    assertNotNull(revision);
    assertEquals(2, content.getRevisionList().size());
    assertSame(revision, content.getRevisionList().get(1));
    transaction.commit();

    // load another instance, different from the one in the list:
    revision = entityManager.find(ContentRevision.class, revision.getId());
    revision.setTextData("CHANGED2");

    // start another TX, replace the "current revision" but not the one
    // in the list:
    transaction.begin();
    document.getContentList().get(0).setCurrent(revision);
    document = entityManager.merge(document); // here's your error!!!
    transaction.commit();

    content = document.getContentList().get(0);
    revision = content.getCurrent();
    assertSame(revision, content.getRevisionList().get(1));
    assertEquals("CHANGED2", revision.getTextData());
}

And there, I got exactly your error. Then I modified the cascading setting on the @OneToMany mapping:

@OneToMany(mappedBy = "content", cascade = { PERSIST, REFRESH, REMOVE }, orphanRemoval = true)
private List<ContentRevision> revisionList;

And the error disappeared :-) ... because I removed CascadeType.MERGE .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM