使用 JPA 和 Spring Boot 的树形实体

in shuxingshiti •  2 months ago 

使用 JPA 和 Spring Boot 的树形实体

在本文中,我们将使用 JPA 与 Spring Boot 和 Spring Data 来在同一个数据库表中建模一个层次关系。为了测试,我们将使用 test-container。

使用树结构有多种情况。例如,我在主题、类别、菜单项和属性键中使用过树结构。在我们的示例中,我们将使用菜单项的情况。

我创建了一个 GitHub 仓库 以供参考源代码。

技术栈如下:

使用 gradle 构建,lombok 避免样板代码,spring 和 spring-boot 负责依赖注入,postgre,JPA,hibernate,spring-data,data-api,ent-core,flyway 用于数据库交互,docker 进行集成测试,使用 junit-jupiter 和 test-containers。层次结构的 API 在 GitHub 仓库 data-api 中定义,可以通过公共 Maven 仓库导入。

让我们开始创建树形实体类。

package io.github.astrapi69.treentity.jpa.entity;

import javax.persistence.Entity;
import javax.persistence.Table;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
import io.github.astrapi69.entity.treeable.TreeUUIDEntity;

@Entity
@Table(name = MenuItems.TABLE_NAME)
@Getter
@Setter
@ToString
@NoArgsConstructor
@SuperBuilder
public class MenuItems extends TreeUUIDEntity<String, MenuItems>
{
   public static final String TABLE_NAME = "menu_items";
   /** Serial Version UID */
   private static final long serialVersionUID = 1L;
}

我们可以看到这里使用了大多数 lombok 注解,以减少样板代码,但更重要的是,我们从基类 TreeUUIDEntity 派生了所有功能。基类 TreeUUIDEntity 是通用的,包含字段如值、父节点、深度,以及它是节点还是叶子节点。TreeUUIDEntity 是 GitHub 仓库 ent-core 的一部分。让我们看看它的代码:

package io.github.astrapi69.entity.treeable;

import javax.persistence.Column;
import javax.persistence.FetchType;
import javax.persistence.ForeignKey;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.MappedSuperclass;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.SuperBuilder;
import io.github.astrapi69.data.treeable.Treeable;
import io.github.astrapi69.entity.uniqueable.UUIDEntity;

@MappedSuperclass
@Getter
@Setter
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
@SuperBuilder
public class TreeUUIDEntity<T, TR extends Treeable<T, TR>> extends UUIDEntity
   implements
      Treeable<T, TR>
{

   /** 该节点的深度。根节点的深度为 0。 */
   @Column(name = "depth")
   int depth;

   /** 一个标志,指示此树实体是节点还是叶子节点 */
   @Column(name = "node")
   boolean node;

   /** 引用父节点的树实体。 */
   @ManyToOne(fetch = FetchType.EAGER)
   @JoinColumn(name = "parent_id", foreignKey = @ForeignKey(name = "fk_treeable_parent_id"))
   TR parent;

   /** 树实体的值 */
   @Column(name = "value", columnDefinition = "TEXT")
   T value;

}

因此,树形功能从 TreeUUIDEntity 派生到具体的实体类 MenuItems。接下来让我们创建相应的 spring-data 仓库:

package io.github.astrapi69.treentity.jpa.repository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

import javax.transaction.Transactional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import io.github.astrapi69.treentity.jpa.entity.MenuItems;

public interface MenuItemsRepository extends JpaRepository<MenuItems, UUID>
{
   List<MenuItems> findByValue(String value);

   @Transactional
   @Query("select entity from MenuItems entity where entity.depth=:depth "
      + " and entity.value=:value") List<MenuItems> findByDepthAndValue(@Param("depth") int depth,
      @Param("value") String value);

   @Transactional
   @Query("select entity from MenuItems entity where entity.depth=:depth "
      + " and entity.value=:value " + " and entity.parent=:parent")
   List<MenuItems> findByDepthAndValueAndParent(@Param("depth") int depth,
      @Param("value") String value, @Param("parent") MenuItems parent);

   @Transactional
   @Query("select entity from MenuItems entity where entity.value=:value "
      + " and entity.parent is null") Optional<MenuItems> findRootByValue(@Param("value") String value);

   @Query("select entity from MenuItems entity where entity.depth=:depth "
      + " and entity.value=:value " + " and entity.parent=:parent " +
      "and entity.node=:node")
   Optional<MenuItems> findByDepthAndValueAndParentAndNode(@Param("depth") int depth,
      @Param("value") String value, @Param("parent") MenuItems parent, @Param("node") boolean node);

   @Query(value = "WITH RECURSIVE ancestors(id, parent_id, value, level) AS ("
      + " SELECT pkp.id, pkp.parent_id, pkp.value, 1 AS level "
      + " FROM menu_items pkp " 
      + " WHERE pkp.id = :treeId " 
      + " UNION ALL "
      + " SELECT parent.id, parent.parent_id, parent.value, child.level + 1 AS level "
      + " FROM menu_items parent " + " JOIN ancestors child "
      + " ON parent.id = child.parent_id " + " )"
      + "SELECT * from ancestors ORDER BY level DESC", nativeQuery = true)
   List<MenuItems> findAncestors(@Param("treeId") UUID treeId);

   @Query(value = "WITH RECURSIVE children(id, parent_id, value) AS ("
      + " SELECT pkp.id, pkp.parent_id, pkp.value, 1 AS level "
      + " FROM menu_items pkp " 
      + " WHERE pkp.id=:treeId " 
      + " UNION ALL "
      + " SELECT parent.id, parent.parent_id, parent.value, child.level + 1 AS level "
      + " FROM menu_items parent " + " JOIN children child "
      + " ON child.id = parent.parent_id) "
      + " SELECT * FROM children "
      , nativeQuery = true)
   List<MenuItems> getAllChildrenWithParent(@Param("treeId") UUID treeId);

   @Query(value = "select * from menu_items pkp where pkp.parent_id =:parent", nativeQuery = true)
   List<MenuItems> getChildren(@Param("parent") UUID parent);
}

在这里,我们有一些用于获取 MenuItems 实体的祖先、直接子节点以及递归获取所有子节点的算法。

让我们开始为 spring-data 仓库类编写一些集成测试,使用 test-containers。使用 test-container 的前提条件是你已经在系统上安装了 docker。接下来我们创建一个抽象类,其中包含一个带有 postgres 数据库的测试容器。

package io.github.astrapi69.treentity.integration;

import java.time.Duration;
import java.util.Map;
import java.util.stream.Stream;

import lombok.NonNull;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.test.context.ContextConfiguration;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.lifecycle.Startables;
import org.testcontainers.shaded.com.google.common.collect.ImmutableMap;

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ContextConfiguration(initializers = AbstractIntegrationTest.Initializer.class)
public class AbstractIntegrationTest
{

   /**
    * 参见 'https://hub.docker.com/_/postgres?tab=tags&page=1&name=12.5'
    */
   private static final String IMAGE_VERSION = "postgres:12.5";
   @Autowired
   protected TestEntityManager entityManager;

   static class Initializer
      implements
         ApplicationContextInitializer<ConfigurableApplicationContext>
   {

      static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(IMAGE_VERSION)
         .withDatabaseName("treentity").withUsername("postgres").withPassword("postgres")
         .withStartupTimeout(Duration.ofSeconds(600));

      private static void startContainers()
      {
         Startables.deepStart(Stream.of(postgres)).join();
         // 我们可以在这里添加更多的容器
         // 例如 rabbitmq 或其他数据库
      }

      private static @NonNull Map<String, Object> createConnectionConfiguration()
      {
         return ImmutableMap.of("spring.datasource.url", postgres.getJdbcUrl(),
            "spring.datasource.username", postgres.getUsername(), "spring.datasource.password",
            postgres.getPassword());
      }

      @Override
      public void initialize(ConfigurableApplicationContext context)
      {
         startContainers();
         ConfigurableEnvironment environment = context.getEnvironment();
         MapPropertySource testcontainers = new MapPropertySource("testcontainers",
            createConnectionConfiguration());
         environment.getPropertySources().addFirst(testcontainers);
      }
   }

}

下一步是在这个基类中编写用于 MenuItemsRepository 的测试。

@Test
public void whenFindAncestors()
{
   String value;
   value = "New";
   MenuItems root = MenuItems.builder().parent(null).depth(0).node(true)
      .value(value).build();

   value = "JPA";
   MenuItems newJpa = MenuItems.builder().parent(root).value(value)
      .node(true).depth(1).build();

   MenuItems savedRoot = repository.save(root);
   MenuItems savedNewJpa = repository.save(newJpa);

   List<MenuItems> newJpaList = repository.findByValue(value);
   assertNotNull(newJpaList);
   assertEquals(1, newJpaList.size());

   MenuItems firstMenuItem = newJpaList.get(0);
   assertEquals(savedNewJpa, firstMenuItem);
   MenuItems parent = firstMenuItem.getParent();
   assertEquals(savedRoot, parent);

   value = "Project";
   MenuItems newProject = MenuItems.builder().parent(root).value(value)
      .node(true).depth(1).build();
   MenuItems savedNewProject = repository.save(newProject);

   List<MenuItems> ancestors = repository.findAncestors(newProject.getId());
   assertNotNull(ancestors);
   ancestors.remove(savedNewProject);
   assertEquals(1, ancestors.size());
   assertEquals(savedRoot, ancestors.get(0));
}

在仓库中还有更多针对 MenuItemsService 的集成测试。请查看示例 GitHub 仓库 中的 MenuItemsService 集成测试。

示例 GitHub 仓库 中的所有源代码都在 MIT 许可证下发布。因此,您可以在私有或商业项目或产品中复制、修改并使用它。

感谢您的阅读。

Authors get paid when people like you upvote their post.
If you enjoyed what you read here, create your account today and start earning FREE STEEM!