JPA双向关联同一个实体的问题

3

我有以下实体:

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

并且这是数据库映射:

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    |                |
+-----------------------------+--------------+------+-----+---------+----------------+

我还有以下要求:
  1. Content.current 一直是 Content.revisionList 的成员(可以将 Content.current 视为“指针”)。
  2. 用户可以向现有的 Content 添加新的 ContentRevision
  3. 用户可以添加一个带有初始 ContentRevision 的新 Content(级联持久化)
  4. 用户可以更改 Content.current(移动“指针”)
  5. 用户可以修改 Content.current.textData,但保存 Content(级联合并)
  6. 用户可以删除 ContentRevision
  7. 用户可以删除 Content(级联删除到 ContentRevision

现在,我的问题是:

  1. 这是最佳方法吗?有什么最佳实践吗?
  2. 当相同实体被引用两次时,级联合并是否安全?(Content.current 也是 Content.revisionList[i]
  3. Content.currentContent.revisionList[i] 是同一实例吗? (Content.current == Content.revisionList[i] ?)

谢谢


@jabu.10245 我非常感谢您的努力。真的谢谢你。

但是,您的测试中有一个问题(缺失的情况):当在使用 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);
    }
}

因为它们不一样:

  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.

奇怪的是,在容器外运行此测试可以成功执行。

可能是懒加载(hibernate.enable_lazy_load_no_trans=true),也可能是其他原因,但绝对不安全

我想知道是否有一种方法可以获得相同的实例。


1
在我看来,对于第三点,如果其中一个是懒加载,则会有相同的实例,但如果两者都是急切获取的,则取决于底层实现。非常好的问题! - Gab
@Gab 谢谢 ;) 我认为这也取决于缓存...但是规范在这个主题上非常模糊。 - Michele Mariotti
如果您只需使用简单的int来满足当前版本的指针需求,那么怎么办呢?您可以实现一个类似于revisionList.get(currentRevision)的getCurrentRevision方法。虽然这可能会使得对CurrentRevision的HQL查询变得复杂,但这取决于您的需求。 - gabrielgiussi
这不是最优的方式:你引入了一个对 order(revisionList) 的函数依赖,因此你必须确保顺序不会改变,或者如果改变了,你必须始终更新索引。 - Michele Mariotti
1个回答

4
当同一实体被引用两次时,级联合并是否安全?
是的。如果您管理一个Content实例,那么它的Content.revisionList和Content.current也将被管理。当刷新实体管理器时,任何这些更改都将被持久化。除非您处理需要合并的瞬态对象,否则不必手动调用EntityManager.merge(...)。
如果您创建一个新的ContentRevision,则使用该新实例调用persist(...)而不是merge(...),并确保它具有对父Content的托管引用,并将其添加到内容列表中。
Content.current和Content.revisionList[i]是同一个实例吗?
是的,应该是的。测试以确保。
Content.current始终是Content.revisionList的成员(将Content.current视为“指针”)。
你可以使用SQL的检查约束来进行检查;或者在Java中,尽管你必须确保revisionList被获取。默认情况下,它是懒加载的,这意味着如果你访问getRevisionList()方法,Hibernate将为此列表运行另一个查询。而且你需要一个正在运行的事务,否则你将会得到一个LazyInitializationException
如果你想要的话,你可以改为eagerly加载列表。或者你可以定义一个entity graph来支持不同查询中的两种策略。
用户可以修改Content.current.textData,但保存Content(级联合并)。
请参见我上面的第一段,Hibernate应该自动保存任何托管实体的更改。
用户可以删除ContentRevision。
if (content.getRevisionList().remove(revision))
    entityManager.remove(revision);

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

用户可以删除内容(级联删除到内容修订版)

在这里,我更喜欢确保在数据库模式中,例如

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

更新

根据要求,我写了一个测试。请参见this gist以查看我使用的ContentContentRevision的实现。

不过我必须做出一个重要更改:Content.current不能真正地是@NotNull,特别是不是DB字段,因为如果是这样,我们将无法同时保存内容和修订版,因为两者都没有ID。因此,该字段必须允许最初为NULL

作为解决方法,我向Content添加了以下方法:

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

这里的验证器确保始终存在一个非空的“current”修订版,并且它包含在修订列表中。
现在是我的测试。
这个测试证明了引用是相同的(问题3),并且只需要持久化“content”,其中“current”和“revisionList [0]”引用相同的实例(问题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));
}

下一步是确保在加载实体后仍然保持其真实性:
@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));
}

即使在更新时:

@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

更新2

在我的电脑上很难重现那种情况,但我最终成功了。这是我目前所做的:

我将所有@OneToMany关系更改为使用延迟加载(默认值),并重新运行了以下测试用例:

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

测试通过了懒加载。请注意,懒加载需要在事务内执行。
由于某些原因,您正在编辑的内容修订实例与一对多列表中的实例不同。为了重现这个问题,我已经修改了我的测试如下:
@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());
}

在那里,我得到了你的错误。然后我修改了@OneToMany映射上的级联设置:

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

错误消失了 :-) ... 因为我删除了CascadeType.MERGE


1
当使用ORM时,数据库触发器/级联等总是一个非常糟糕的想法(在刷新后,entityManager状态将变为脏)。 - Gab
1
我同意最后一点。然而,在这种情况下,级联已经在JPA映射中设置好了,所以ON DELETE CASCADE实际上并没有什么不同。另一方面,如果有DB操作发生在JPA之外,或者您使用纯SQL手动删除一个“content”,那么将其级联更加安全。 - jabu.10245
@jabu.10245 我仍然不确定它是否安全,特别是在子实体上还有 @Version 属性的复杂情况下。如果您可以为第 2 和第 3 点提供一些证据(是的,我很懒 ;)),我会接受您的答案。 - Michele Mariotti
1
更新了我的回答。忘记提到我在测试部分使用了Arquillian和JUnit。数据库是PostgreSQL 8.4,服务器正在运行Wildfly 8.2.0。 - jabu.10245
1
因此,我们可以得出结论:如果延迟加载始终发生在用于加载父实体的同一事务(确切地说是持久化上下文)中,则可以安全地使用双重关系和双重级联合并(在这种情况下,延迟加载相当无用)。再次感谢您,让我了解了底层发生的情况。 - Michele Mariotti
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接