Рубрики
Без рубрики

Лучший способ сопоставить связь “многие ко многим” с дополнительными столбцами при использовании JPA и Hibernate

Ассоциация JPA “многие ко многим” может содержать дополнительные столбцы, и в этом случае вам необходимо сопоставить промежуточную таблицу как отдельную сущность.

Автор оригинала: Vlad Mihalcea.

Для простого отношения базы данных “многие ко многим” вы можете использовать аннотацию @ManyToMany JPA и, следовательно, скрыть таблицу соединений .

Однако иногда вам требуется больше, чем два столбца внешнего ключа в таблице объединения, и для этой цели вам необходимо заменить ассоциацию @ManyToMany двумя двунаправленными ассоциациями @OneToMany . В отличие от однонаправленного @OneToMany , двунаправленное отношение является лучшим способом сопоставления отношения базы данных “один ко многим”, для которого требуется коллекция дочерних элементов на родительской стороне

В этой статье мы рассмотрим, как можно сопоставить отношения базы данных “многие ко многим”, используя промежуточную сущность для таблицы объединения. Таким образом, мы можем сопоставить дополнительные столбцы, которые в противном случае было бы невозможно сохранить, используя аннотацию @ManyToMany JPA.

Предполагая, что у нас есть следующие таблицы базы данных:

Первое, что нам нужно, – это сопоставить составной первичный ключ, который принадлежит промежуточной таблице соединений. Как объяснено в этой статье , нам нужен @Встраиваемый тип для хранения составного идентификатора сущности:

@Embeddable
public class PostTagId
    implements Serializable {

    @Column(name = "post_id")
    private Long postId;

    @Column(name = "tag_id")
    private Long tagId;

    private PostTagId() {}

    public PostTagId(
        Long postId, 
        Long tagId) {
        this.postId = postId;
        this.tagId = tagId;
    }

    //Getters omitted for brevity

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;

        if (o == null || getClass() != o.getClass()) 
            return false;

        PostTagId that = (PostTagId) o;
        return Objects.equals(postId, that.postId) && 
               Objects.equals(tagId, that.tagId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(postId, tagId);
    }
}

Есть два очень важных аспекта, которые следует учитывать при сопоставлении @встраиваемого составного идентификатора:

  1. Вам нужно, чтобы тип @Встраиваемый был Сериализуемым
  2. Тип @Встраиваемый должен переопределять методы equals и хэш-кода по умолчанию, основанные на двух значениях идентификатора первичного ключа.

Затем нам нужно сопоставить таблицу соединений с помощью выделенной сущности:

@Entity(name = "PostTag")
@Table(name = "post_tag")
public class PostTag {

    @EmbeddedId
    private PostTagId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("postId")
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("tagId")
    private Tag tag;

    @Column(name = "created_on")
    private Date createdOn = new Date();

    private PostTag() {}

    public PostTag(Post post, Tag tag) {
        this.post = post;
        this.tag = tag;
        this.id = new PostTagId(post.getId(), tag.getId());
    }

    //Getters and setters omitted for brevity

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;

        if (o == null || getClass() != o.getClass())
            return false;

        PostTag that = (PostTag) o;
        return Objects.equals(post, that.post) &&
               Objects.equals(tag, that.tag);
    }

    @Override
    public int hashCode() {
        return Objects.hash(post, tag);
    }
}

Налоговая сущность собирается сопоставить @OneToMany сторону для атрибута tag в Теге Post присоединиться к сущности:

@Entity(name = "Tag")
@Table(name = "tag")
@NaturalIdCache
@Cache(
    usage = CacheConcurrencyStrategy.READ_WRITE
)
public class Tag {

    @Id
    @GeneratedValue
    private Long id;

    @NaturalId
    private String name;

    @OneToMany(
        mappedBy = "tag",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private List posts = new ArrayList<>();

    public Tag() {
    }

    public Tag(String name) {
        this.name = name;
    }

    //Getters and setters omitted for brevity

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Tag tag = (Tag) o;
        return Objects.equals(name, tag.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

Объект Tag помечен следующими аннотациями, относящимися к режиму гибернации:

  1. Аннотация @Natural позволяет нам извлекать объект Tag по его бизнес-ключу.
  2. В аннотации @Cache отмечена стратегия параллелизма кэша .
  3. @NaturalIdCache указывает Hibernate кэшировать идентификатор сущности, связанный с данным бизнес-ключом.

Для получения более подробной информации об аннотациях @Natural и @NaturalIdCache ознакомьтесь с этой статьей .

Имея эти аннотации на месте, мы можем извлечь объект Tag без необходимости обращаться к базе данных.

И сущность Post будет отображать @OneToMany сторону для атрибута post в теге Post присоединиться к сущности:

@Entity(name = "Post")
@Table(name = "post")
public class Post {

    @Id
    @GeneratedValue
    private Long id;

    private String title;

    @OneToMany(
        mappedBy = "post", 
        cascade = CascadeType.ALL, 
        orphanRemoval = true
    )
    private List tags = new ArrayList<>();

    public Post() {
    }

    public Post(String title) {
        this.title = title;
    }

    //Getters and setters omitted for brevity

    public void addTag(Tag tag) {
        PostTag postTag = new PostTag(this, tag);
        tags.add(postTag);
        tag.getPosts().add(postTag);
    }

    public void removeTag(Tag tag) {
        for (Iterator iterator = tags.iterator(); 
             iterator.hasNext(); ) {
            PostTag postTag = iterator.next();

            if (postTag.getPost().equals(this) &&
                    postTag.getTag().equals(tag)) {
                iterator.remove();
                postTag.getTag().getPosts().remove(postTag);
                postTag.setPost(null);
                postTag.setTag(null);
            }
        }
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;

        if (o == null || getClass() != o.getClass()) 
            return false;

        Post post = (Post) o;
        return Objects.equals(title, post.title);
    }

    @Override
    public int hashCode() {
        return Objects.hash(title);
    }
}

Обратите внимание, что сущность Post содержит добавить тег и удалить тег служебные методы, которые необходимы каждой двунаправленной ассоциации , чтобы все стороны ассоциации оставались синхронизированными.

Хотя мы могли бы добавить те же методы добавления/удаления в сущность Tag , маловероятно, что эти ассоциации будут установлены из сущности Tag , поскольку пользователи работают с сущностями Post .

Чтобы лучше визуализировать взаимосвязи сущностей, ознакомьтесь со следующей диаграммой:

Во-первых, давайте сохраним некоторые объекты Tag , которые мы позже свяжем с Сообщением :

Tag misc = new Tag("Misc");
Tag jdbc = new Tag("JDBC");
Tag hibernate = new Tag("Hibernate");
Tag jooq = new Tag("jOOQ");

doInJPA(entityManager -> {
    entityManager.persist( misc );
    entityManager.persist( jdbc );
    entityManager.persist( hibernate );
    entityManager.persist( jooq );
});

Теперь, когда мы сохраняем две Записи сущности:

Session session = entityManager
    .unwrap( Session.class );

Tag misc = session
    .bySimpleNaturalId(Tag.class)
    .load( "Misc" );

Tag jdbc = session
    .bySimpleNaturalId(Tag.class)
    .load( "JDBC" );

Tag hibernate = session
    .bySimpleNaturalId(Tag.class)
    .load( "Hibernate" );

Tag jooq = session
    .bySimpleNaturalId(Tag.class)
    .load( "jOOQ" );

Post hpjp1 = new Post(
    "High-Performance Java Persistence 1st edition"
);
hpjp1.setId(1L);

hpjp1.addTag(jdbc);
hpjp1.addTag(hibernate);
hpjp1.addTag(jooq);
hpjp1.addTag(misc);

entityManager.persist(hpjp1);

Post hpjp2 = new Post(
    "High-Performance Java Persistence 2nd edition"
);
hpjp2.setId(2L);

hpjp2.addTag(jdbc);
hpjp2.addTag(hibernate);
hpjp2.addTag(jooq);

entityManager.persist(hpjp2);

Hibernate создает следующие инструкции SQL:

INSERT INTO post (title, id) 
VALUES ('High-Performance Java Persistence 1st edition', 1)

INSERT INTO post_tag (created_on, post_id, tag_id) 
VALUES ('2017-07-26 13:14:08.988', 1, 2)

INSERT INTO post_tag (created_on, post_id, tag_id) 
VALUES ('2017-07-26 13:14:08.989', 1, 3)

INSERT INTO post_tag (created_on, post_id, tag_id) 
VALUES ('2017-07-26 13:14:08.99', 1, 4)

INSERT INTO post_tag (created_on, post_id, tag_id) 
VALUES ('2017-07-26 13:14:08.99', 1, 1)

INSERT INTO post (title, id) 
VALUES ('High-Performance Java Persistence 2nd edition', 2)

INSERT INTO post_tag (created_on, post_id, tag_id) 
VALUES ('2017-07-26 13:14:08.992', 2, 3)

INSERT INTO post_tag (created_on, post_id, tag_id) 
VALUES ('2017-07-26 13:14:08.992', 2, 4)

INSERT INTO post_tag (created_on, post_id, tag_id) 
VALUES ('2017-07-26 13:14:08.992', 2, 2)

Теперь, поскольку объект Разное Тег был добавлен по ошибке, мы можем удалить его следующим образом:

Tag misc = entityManager.unwrap( Session.class )
    .bySimpleNaturalId(Tag.class)
    .load( "Misc" );

Post post = entityManager.createQuery(
    "select p " +
    "from Post p " +
    "join fetch p.tags pt " +
    "join fetch pt.tag " +
    "where p.id = :postId", Post.class)
.setParameter( "postId", 1L )
.getSingleResult();

post.removeTag( misc );

Спящий режим, генерирующий следующие инструкции SQL:

SELECT p.id AS id1_0_0_,
       p_t.created_on AS created_1_1_1_,
       p_t.post_id AS post_id2_1_1_,
       p_t.tag_id AS tag_id3_1_1_,
       t.id AS id1_2_2_,
       p.title AS title2_0_0_,
       p_t.post_id AS post_id2_1_0__,
       p_t.created_on AS created_1_1_0__,
       p_t.tag_id AS tag_id3_1_0__,
       t.name AS name2_2_2_
FROM   post p
INNER JOIN 
       post_tag p_t ON p.id = p_t.post_id
INNER JOIN 
       tag t ON p_t.tag_id = t.id
WHERE  p.id = 1

SELECT p_t.tag_id AS tag_id3_1_0_,
       p_t.created_on AS created_1_1_0_,
       p_t.post_id AS post_id2_1_0_,
       p_t.created_on AS created_1_1_1_,
       p_t.post_id AS post_id2_1_1_,
       p_t.tag_id AS tag_id3_1_1_
FROM   post_tag p_t
WHERE  p_t.tag_id = 1

DELETE 
FROM   post_tag 
WHERE  post_id = 1 AND tag_id = 1

Второй запрос SELECT необходим для этой строки в методе утилиты удалить тег :

postTag.getTag().getPosts().remove(postTag);

Однако, если вам не нужно перемещаться по всем сущностям Post , связанным с Тегом , вы можете удалить коллекцию posts из сущности Tag , и эта дополнительная инструкция SELECT больше не будет выполняться.

Сущность Tag больше не будет сопоставлять Тег Post |/@OneToMany двунаправленную ассоциацию.

@Entity(name = "Tag")
@Table(name = "tag")
@NaturalIdCache
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Tag {

    @Id
    @GeneratedValue
    private Long id;

    @NaturalId
    private String name;

    public Tag() {
    }

    public Tag(String name) {
        this.name = name;
    }

    //Getters omitted for brevity

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;

        if (o == null || getClass() != o.getClass()) 
            return false;

        Tag tag = (Tag) o;
        return Objects.equals(name, tag.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

Объект Post Tax и его PostTagId | @Встраиваемый идентичны предыдущему примеру.

Однако Запись организация добавить налог и удалить тег упрощены следующим образом:

public void addTag(Tag tag) {
    PostTag postTag = new PostTag(this, tag);
    tags.add(postTag);
}

public void removeTag(Tag tag) {
    for (Iterator iterator = tags.iterator(); 
         iterator.hasNext(); ) {
        PostTag postTag = iterator.next();

        if (postTag.getPost().equals(this) &&
                postTag.getTag().equals(tag)) {
            iterator.remove();
            postTag.setPost(null);
            postTag.setTag(null);
        }
    }
}

Остальная часть сущности Post такая же, как и в предыдущем примере, как показано на следующей диаграмме:

Вставка тега Post сущностей приведет к отображению тех же операторов SQL, что и ранее.

Но при удалении объекта Post Tax Hibernate выполнит один запрос SELECT, а также одну инструкцию DELETE:

SELECT p.id AS id1_0_0_,
       p_t.created_on AS created_1_1_1_,
       p_t.post_id AS post_id2_1_1_,
       p_t.tag_id AS tag_id3_1_1_,
       t.id AS id1_2_2_,
       p.title AS title2_0_0_,
       p_t.post_id AS post_id2_1_0__,
       p_t.created_on AS created_1_1_0__,
       p_t.tag_id AS tag_id3_1_0__,
       t.name AS name2_2_2_
FROM   post p
INNER JOIN 
       post_tag p_t ON p.id = p_t.post_id
INNER JOIN 
       tag t ON p_t.tag_id = t.id
WHERE  p.id = 1

DELETE 
FROM   post_tag 
WHERE  post_id = 1 AND tag_id = 1

Хотя сопоставление отношений базы данных “многие ко многим” с использованием аннотации @ManyToMany, несомненно, проще, когда вам нужно сохранить дополнительные столбцы в таблице объединения, вам необходимо сопоставить таблицу объединения как выделенную сущность.

Хотя немного больше работы, ассоциация работает так же, как и ее @ManyToMany аналог, и на этот раз мы можем Перечислять коллекции, не беспокоясь о проблемах с производительностью операторов SQL.

При сопоставлении таблицы промежуточного соединения лучше сопоставлять только одну сторону как двунаправленную @OneToMany ассоциацию, так как в противном случае при удалении сущности промежуточного соединения будет выдан второй оператор SELECT.