使用 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 许可证下发布。因此,您可以在私有或商业项目或产品中复制、修改并使用它。
感谢您的阅读。