JPA与同一实体的双重关系

JPA double relation with the same Entity

我有这些实体:

@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 视为 "pointer")。
  2. 用户可以将新的 ContentRevision 添加到现有的 Content
  3. 用户可以添加一个新的 Content 和一个初始的 ContentRevision(级联持续)
  4. 用户可以更改Content.current(移动"pointer")
  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. 如果我在两个属性上启用级联,则会抛出异常

    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. 如果我禁用电流级联,更改将丢失。

奇怪的是,运行在容器外执行此测试会导致成功执行。

也许是延迟加载 (hibernate.enable_lazy_load_no_trans=true),也许是其他原因,但绝对是 不安全.

我想知道是否有办法获得相同的实例。

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

是的。如果您管理 Content 的实例,那么它的 Content.revisionListContent.current 也会被管理。刷新实体管理器时,其中任何一个的更改都将保留。您不必手动调用 EntityManager.merge(...),除非您正在处理需要合并的临时对象。

如果您创建一个新的 ContentRevision,则使用该新实例调用 persist(...) 而不是 merge(...),并确保它具有对父 Content 的托管引用, 也将其添加到内容列表中。

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

是的,应该是。测试一下确定。

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

您可以使用检查约束在 SQL 中进行检查;或在 Java 中,但您必须确保已获取 revisionList。默认情况下,它是延迟获取的,这意味着如果您访问 getRevisionList() 方法,Hibernate 将 运行 对该列表进行另一个查询。为此,您需要 运行ning 交易,否则您将获得 LazyInitializationException.

您可以改为加载列表 eagerly, if that's what you want. Or you could define a entity graph,以便能够在不同的查询中支持这两种策略。

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

参见我上面的第一段,Hibernate 应该自动保存对任何托管实体的更改。

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)

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

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

更新

应要求,我写了一个测试。有关我使用的 ContentContentRevision 的实现,请参见 this gist

我不得不做一个重要的改变: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 where current和[=40就够了=] 引用同一个实例(问题 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.