【问题标题】:JPA double relation with the same EntityJPA 与同一实体的双重关系
【发布时间】:2015-12-02 09:45:20
【问题描述】:

我有这些实体:

@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. 用户可以将新的ContentRevision 添加到现有的Content
  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. 如果我在两个属性上启用级联,则会引发异常

    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),也可能是其他原因,但这绝对是不安全

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

【问题讨论】:

  • 恕我直言,对于第三点,我想说如果两者中的一个被延迟加载但如果两者都被急切地获取,我认为这取决于底层实现。很好的问题!
  • @Gab 谢谢 ;) 我认为这也取决于缓存......但是关于这个主题的规范非常模糊。
  • 如果你实现你的指针需要用一个简单的 int 代替当前版本?您可以实现一个 getCurrentRevision,如 revisionList.get(currentRevision)。虽然它可能会使 CurrentRevision 上的 HQL 查询复杂化,但这取决于您的需求。
  • 这不是最优的:你向order(revisionList)引入了一个函数依赖,所以你必须保证顺序不会改变,或者如果改变你必须总是更新索引也是。

标签: java hibernate jpa jpa-2.1


【解决方案1】:

同一个实体被引用两次时级联合并是否安全?

是的。如果您管理Content 的实例,那么它的Content.revisionListContent.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 */);

用户可以删除内容(级联删除到 ContentRevision)

这里我宁愿确保在数据库架构中,例如

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

更新

按照要求,我写了一个测试。我使用的ContentContentRevision的实现见this gist

我不得不做一个重要的改变:Content.current 不能真的是@NotNull,尤其不是 DB 字段,因为如果是,那么我们不能同时保留内容和修订,因为两者还没有身份证。因此,该字段最初必须允许为NULL

作为一种解决方法,我将以下方法添加到Content

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

这里验证器确保始终存在一个非空的current 修订版并且它包含在修订版列表中。

现在这是我的测试。

这证明引用是相同的(问题 3)并且足以持久化 content 其中 currentrevisionList[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

【讨论】:

  • 数据库触发器/级联等在使用 ORM 时总是一个非常糟糕的主意(刷新后实体管理器状态会变脏)
  • 我同意最后一点。然而,在这种情况下,级联已经在 J​​PA 映射中设置,所以 ON DELETE CASCADE 并没有什么不同,真的。另一方面,如果在 JPA 之外发生了 DB 操作,或者您将使用普通 SQL 手动删除一个 content,那么将其级联起来会更安全。
  • @jabu.10245 我仍然不确定它是否安全,尤其是在子实体上还有@Version 属性的复杂情况下。如果您可以为 2. 和 3. 提供一些证据(是的,我很懒 ;))我会接受您的回答。
  • 更新了我的答案。忘了提到我在测试部分使用了 Arquillian 和 JUnit。 DB 为 PostgreSQL 8.4,服务器运行 Wildfly 8.2.0
  • 所以我们可以得出结论:使用双重关系是安全的,通过双重级联合并,如果延迟加载总是发生在用于加载父实体的同一个事务(准确地说:持久性上下文)中(在这种情况下,使延迟加载变得毫无用处)。再次感谢你,你让我明白了幕后发生的事情。
猜你喜欢
  • 1970-01-01
  • 2019-04-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多