diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f29bcd4..2f57f92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,19 +14,19 @@ permissions: jobs: test: - name: Maven Test (JDK 8) + name: Maven Verify (JDK 21) runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 - - name: Set up JDK 8 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin - java-version: '8' + java-version: '21' cache: maven - - name: Run tests - run: mvn --batch-mode --no-transfer-progress test + - name: Verify + run: mvn --batch-mode --no-transfer-progress verify diff --git a/README.MD b/README.MD index 583f5f6..1c1d704 100644 --- a/README.MD +++ b/README.MD @@ -1,23 +1,53 @@ -# Introduction +# SQL Hierarchy Models [![CI](https://github.com/Kotryos/sql-hierarchy-models/actions/workflows/ci.yml/badge.svg)](https://github.com/Kotryos/sql-hierarchy-models/actions/workflows/ci.yml) -The Nested Set Model is an approach to storing hierarchical data structures in relational databases. -It is an alternative to more common Adjacency List Model. -The Nested Set Model has a clear advantage over Adjacency List Model for operations like finding path for a node, retrieving the whole tree, finding depth of a node etc. -For example, in Adjacency List it is impossible to find a path for a node with pure SQL without knowing the depth of that node. -On the other hand, create/delete operations in Nested Set Model are much more complicated, since these operations cause the recalculation of "left" and "right" values for the whole tree. -As a consequence to the aforementioned, the write lock on the table is necessary for create/delete, which would require a stored procedure or SERIALIZABLE isolation level (may lead to heavy performance problems). +This project compares relational models for hierarchical data in SQL databases. +The active codebase is being modernized around Java 21, Spring Boot, jOOQ, Flyway, JUnit 5, AssertJ, and Testcontainers. -Summing up, the Nested Set Model is suitable for systems where different reads are frequent while writes are not needed or usually occur in batch at predefined time. +The original 2016 Spring Data JPA nested-set demo is preserved in the [`legacy-2016`](https://github.com/Kotryos/sql-hierarchy-models/tree/legacy-2016) tag. -This project demonstrates Spring Data JPA powered implementation of different reads in the Nested Set Model. +## Current Model -# Implemented operations +The first modernized model is the Nested Set Model. +It stores each node with `lft` and `rgt` interval values and currently implements: - - retrieval of all leaf nodes - - path retrieval - - retrieval of child nodes for a node - - retrieval of a node depth +- retrieval of all leaf nodes +- path retrieval +- flattened subtree retrieval +- node depth retrieval -# How to run -Run `mvn test` from the project root (requires Maven 3). \ No newline at end of file +The current integration tests use this tree: + +```text +Vehicles [1, 18] +|-- Motor Vehicles [2, 11] +| |-- Cars [3, 8] +| | |-- Convertibles [4, 5] +| | `-- Minivans [6, 7] +| `-- Buses [9, 10] +`-- Rail Vehicles [12, 17] + |-- Trains [13, 14] + `-- Trams [15, 16] +``` + +## How To Run + +Run the full verification build from the project root: + +```shell +mvn verify +``` + +The current baseline uses Flyway migrations and Testcontainers-backed integration tests for PostgreSQL and MySQL. +Docker must be available to run `mvn verify`. + +## jOOQ Code Generation + +jOOQ table metadata is generated from the Flyway SQL migrations during the Maven `generate-sources` phase. +Generated files are written to `target/generated-sources/jooq` and are not committed. + +Run code generation explicitly with: + +```shell +mvn generate-sources +``` diff --git a/pom.xml b/pom.xml index 4aa0ff3..c5f4371 100644 --- a/pom.xml +++ b/pom.xml @@ -1,114 +1,128 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.github.constantinet - nested-set-model-with-spring-data-jpa-example - 1.0-SNAPSHOT + + org.springframework.boot + spring-boot-starter-parent + 3.5.15 + + + + dev.kotryos + sql-hierarchy-models + 0.1.0-SNAPSHOT + sql-hierarchy-models + Comparing SQL hierarchy models with Spring Boot and jOOQ - 1.8 - 2.3.4 - 2.1.1 - 5.1.2.Final - 4.3.3.RELEASE - 1.10.4.RELEASE - 1.16.8 - 4.12 - 2.0.0.0 - 1.3.0 - 2.5.3 + 21 - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.5.1 - - ${java.version} - ${java.version} - - - - - - org.hsqldb - hsqldb - ${hsqldb.version} - - - org.apache.commons - commons-dbcp2 - ${commons-dbcp2.version} - - - org.hibernate - hibernate-entitymanager - ${hibernate.version} + org.springframework.boot + spring-boot-starter-jooq - org.springframework - spring-core - ${spring-framework.version} + org.flywaydb + flyway-core - org.springframework.data - spring-data-jpa - ${spring-data-jpa.version} + org.flywaydb + flyway-database-postgresql - org.springframework - spring-tx - ${spring-framework.version} + org.flywaydb + flyway-mysql + - org.projectlombok - lombok - ${lombok.version} + org.springframework.boot + spring-boot-starter-test + test - - junit - junit - ${junit.version} + org.testcontainers + junit-jupiter test - org.hamcrest - java-hamcrest - ${java-hamcrest.version} + org.testcontainers + postgresql test - org.springframework - spring-test - ${spring-framework.version} + org.postgresql + postgresql test - com.github.springtestdbunit - spring-test-dbunit - ${spring-test-dbunit.version} + org.testcontainers + mysql test - org.dbunit - dbunit - ${dbunit.version} + com.mysql + mysql-connector-j test - - - junit - junit - - - \ No newline at end of file + + + + org.jooq + jooq-codegen-maven + ${jooq.version} + + + generate-jooq + generate-sources + + generate + + + + + + org.jooq + jooq-meta-extensions + ${jooq.version} + + + + + + org.jooq.meta.extensions.ddl.DDLDatabase + + + scripts + src/main/resources/db/migration/*.sql + + + sort + semantic + + + defaultNameCase + lower + + + + + dev.kotryos.sqlhierarchy.jooq + target/generated-sources/jooq + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/com/github/constantinet/nestedsetexample/entity/Node.java b/src/main/java/com/github/constantinet/nestedsetexample/entity/Node.java deleted file mode 100644 index 4fef239..0000000 --- a/src/main/java/com/github/constantinet/nestedsetexample/entity/Node.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.constantinet.nestedsetexample.entity; - -import lombok.*; - -import javax.persistence.*; - -@Getter -@Setter -@ToString -@NoArgsConstructor -@AllArgsConstructor -@Entity -@Table(name = "nodes") -public class Node { - - @Id - @GeneratedValue(strategy = GenerationType.AUTO) - private Long id; - - @Column(unique = true, nullable = false) - private String value; - - @Column(name = "lft", unique = true, nullable = false) - private Integer left; - - @Column(name = "rght", unique = true, nullable = false) - private Integer right; - - public Node(final Long id, final String value) { - this.id = id; - this.value = value; - } -} \ No newline at end of file diff --git a/src/main/java/com/github/constantinet/nestedsetexample/repository/NodeRepository.java b/src/main/java/com/github/constantinet/nestedsetexample/repository/NodeRepository.java deleted file mode 100644 index bb0aa89..0000000 --- a/src/main/java/com/github/constantinet/nestedsetexample/repository/NodeRepository.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.constantinet.nestedsetexample.repository; - -import com.github.constantinet.nestedsetexample.entity.Node; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; - -import java.util.Collection; -import java.util.List; - -public interface NodeRepository extends Repository { - - /** - * Retrieves all nodes with no child nodes (i.e. leaf nodes). - * - * @return {@code Collection} of {@code Node} objects - */ - @Query("SELECT n FROM Node n WHERE n.right = n.left + 1") - Collection findLeafNodes(); - - /** - * Retrieves the path for passed node. - *
- * Path is a {@code List} object where first element is the root node - * and the last element is the immediate parent of passed node. - * - * @param node a node to retrieve a path for - * @return list of nodes or empty list if passed node was the root node or was not found - */ - @Query("SELECT parent FROM Node node, Node parent " + - "WHERE node.left BETWEEN parent.left AND parent.right " + - "AND parent != ?1 " + - "AND node = ?1 ORDER BY node.left") - List findPathForNode(Node node); - - /** - * Retrieves a list that represents a flattened tree of child nodes for passed node. - *
- * The first element in the returned {@code List} object is the left-most child of passed node - * followed by the left-most child of immediate descendant, then followed by the second child - * of passed node if there are no more child nodes of it's left-most child and so on. - * - * @param node a node to retrieve child nodes for - * @return list of nodes or empty list if passed node was the root node or was not found - */ - @Query("SELECT child FROM Node node, Node child " + - "WHERE (child.left BETWEEN node.left AND node.right) " + - "AND (child.right BETWEEN node.left AND node.right) " + - "AND child != ?1 " + - "AND node = ?1 ORDER BY child.left") - List findChildNodesForNode(Node node); - - /** - * Retrieves a depth of a node. - * - * @param node a node to retrieve depth for - * @return depth of a node in the range of 0...n - */ - @Query("SELECT count(parent) FROM Node node, Node parent " + - "WHERE node.left BETWEEN parent.left AND parent.right " + - "AND parent != ?1 AND node = ?1") - int findNodeDepth(Node node); -} \ No newline at end of file diff --git a/src/main/java/dev/kotryos/sqlhierarchy/SqlHierarchyModelsApplication.java b/src/main/java/dev/kotryos/sqlhierarchy/SqlHierarchyModelsApplication.java new file mode 100644 index 0000000..7111e4a --- /dev/null +++ b/src/main/java/dev/kotryos/sqlhierarchy/SqlHierarchyModelsApplication.java @@ -0,0 +1,12 @@ +package dev.kotryos.sqlhierarchy; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SqlHierarchyModelsApplication { + + public static void main(String[] args) { + SpringApplication.run(SqlHierarchyModelsApplication.class, args); + } +} diff --git a/src/main/java/dev/kotryos/sqlhierarchy/nestedset/NestedSetNode.java b/src/main/java/dev/kotryos/sqlhierarchy/nestedset/NestedSetNode.java new file mode 100644 index 0000000..6788cf0 --- /dev/null +++ b/src/main/java/dev/kotryos/sqlhierarchy/nestedset/NestedSetNode.java @@ -0,0 +1,4 @@ +package dev.kotryos.sqlhierarchy.nestedset; + +public record NestedSetNode(Long id, String label, int left, int right) { +} diff --git a/src/main/java/dev/kotryos/sqlhierarchy/nestedset/NestedSetNodeRepository.java b/src/main/java/dev/kotryos/sqlhierarchy/nestedset/NestedSetNodeRepository.java new file mode 100644 index 0000000..6d986cb --- /dev/null +++ b/src/main/java/dev/kotryos/sqlhierarchy/nestedset/NestedSetNodeRepository.java @@ -0,0 +1,80 @@ +package dev.kotryos.sqlhierarchy.nestedset; + +import org.jooq.DSLContext; +import org.jooq.Record; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static dev.kotryos.sqlhierarchy.jooq.Tables.NESTED_SET_NODE; + +@Repository +public class NestedSetNodeRepository { + + private final DSLContext dsl; + + public NestedSetNodeRepository(DSLContext dsl) { + this.dsl = dsl; + } + + public List findLeafNodes() { + var node = NESTED_SET_NODE.as("node"); + + return dsl.select(node.ID, node.LABEL, node.LFT, node.RGT) + .from(node) + .where(node.RGT.eq(node.LFT.plus(1))) + .fetch(record -> mapNode(record, node)); + } + + public List findPath(long nodeId) { + var current = NESTED_SET_NODE.as("node"); + var parent = NESTED_SET_NODE.as("parent"); + + return dsl.select(parent.ID, parent.LABEL, parent.LFT, parent.RGT) + .from(current, parent) + .where(current.LFT.between(parent.LFT, parent.RGT)) + .and(parent.ID.ne(nodeId)) + .and(current.ID.eq(nodeId)) + .orderBy(parent.LFT) + .fetch(record -> mapNode(record, parent)); + } + + public List findSubtree(long rootId) { + var current = NESTED_SET_NODE.as("node"); + var child = NESTED_SET_NODE.as("child"); + + return dsl.select(child.ID, child.LABEL, child.LFT, child.RGT) + .from(current, child) + .where(child.LFT.between(current.LFT, current.RGT)) + .and(child.RGT.between(current.LFT, current.RGT)) + .and(child.ID.ne(rootId)) + .and(current.ID.eq(rootId)) + .orderBy(child.LFT) + .fetch(record -> mapNode(record, child)); + } + + public int findDepth(long nodeId) { + var current = NESTED_SET_NODE.as("node"); + var parent = NESTED_SET_NODE.as("parent"); + + return dsl.fetchCount( + dsl.selectOne() + .from(current, parent) + .where(current.LFT.between(parent.LFT, parent.RGT)) + .and(parent.ID.ne(nodeId)) + .and(current.ID.eq(nodeId)) + ); + } + + private NestedSetNode mapNode( + Record record, + dev.kotryos.sqlhierarchy.jooq.tables.NestedSetNode node + ) { + return new NestedSetNode( + record.get(node.ID), + record.get(node.LABEL), + record.get(node.LFT), + record.get(node.RGT) + ); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..6b2af19 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + application: + name: sql-hierarchy-models diff --git a/src/main/resources/db/migration/V1__create_nested_set_node.sql b/src/main/resources/db/migration/V1__create_nested_set_node.sql new file mode 100644 index 0000000..007d562 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_nested_set_node.sql @@ -0,0 +1,7 @@ +CREATE TABLE nested_set_node ( + id BIGINT NOT NULL PRIMARY KEY, + label VARCHAR(255) NOT NULL UNIQUE, + lft INTEGER NOT NULL UNIQUE, + rgt INTEGER NOT NULL UNIQUE, + CONSTRAINT chk_nested_set_node_bounds CHECK (lft < rgt) +); diff --git a/src/test/java/com/github/constantinet/nestedsetexample/repository/NodeRepositoryTest.java b/src/test/java/com/github/constantinet/nestedsetexample/repository/NodeRepositoryTest.java deleted file mode 100644 index 2f56671..0000000 --- a/src/test/java/com/github/constantinet/nestedsetexample/repository/NodeRepositoryTest.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.github.constantinet.nestedsetexample.repository; - -import com.github.constantinet.nestedsetexample.entity.Node; -import com.github.springtestdbunit.TransactionDbUnitTestExecutionListener; -import com.github.springtestdbunit.annotation.DatabaseSetup; -import com.github.springtestdbunit.annotation.DbUnitConfiguration; -import org.hamcrest.Matcher; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.TestExecutionListeners; -import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; -import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; -import org.springframework.test.context.support.DirtiesContextTestExecutionListener; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collection; -import java.util.List; - -import static org.hamcrest.Matchers.*; -import static org.junit.Assert.assertThat; - -@RunWith(SpringJUnit4ClassRunner.class) -@ContextConfiguration("classpath:test-jpa-context.xml") -@TestExecutionListeners({DependencyInjectionTestExecutionListener.class, - DirtiesContextTestExecutionListener.class, - TransactionDbUnitTestExecutionListener.class}) -@DbUnitConfiguration(databaseConnection = {"testDataSource"}) -@Transactional -public class NodeRepositoryTest { - - @Autowired - private NodeRepository nodeRepository; - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindLeafNodes_whenNodesExist_nonEmptyCollectionReturned() { - // when - final Collection leafNodes = nodeRepository.findLeafNodes(); - - // then - assertThat(leafNodes, containsInAnyOrder( - getAllOfMatcher(8L, "Convertibles", 4, 5), - getAllOfMatcher(9L, "Minivans", 6, 7), - getAllOfMatcher(5L, "Buses", 9, 10), - getAllOfMatcher(6L, "Trains", 13, 14), - getAllOfMatcher(7L, "Trams", 15, 16) - )); - } - - @Test - public void testFindLeafNodes_whenNoNodesExist_emptyCollectionReturned() { - // when - final Collection leafNodes = nodeRepository.findLeafNodes(); - - // then - assertThat(leafNodes, empty()); - } - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindPathForNode_whenLeafNodePassed_nonEmptyListReturned() { - // given - final Node leafNode = new Node(8L, "Convertibles", 4, 5); - - // when - final List path = nodeRepository.findPathForNode(leafNode); - - // then - assertThat(path, contains( - getAllOfMatcher(1L, "Vehicles", 1, 18), - getAllOfMatcher(2L, "Motor Vehicles", 2, 11), - getAllOfMatcher(4L, "Cars", 3, 8) - )); - } - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindPathForNode_whenRootNodePassed_emptyListReturned() { - // given - final Node rootNode = new Node(1L, "Vehicles", 1, 18); - - // when - final List path = nodeRepository.findPathForNode(rootNode); - - // then - assertThat(path, empty()); - } - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindPathForNode_whenNonExistingNodePassed_emptyListReturned() { - // given - final Node nonExistingNode = new Node(10L, "Some Node"); - - // when - final List path = nodeRepository.findPathForNode(nonExistingNode); - - // then - assertThat(path, empty()); - } - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindChildNodesForNode_whenLeafNodePassed_emptyListReturned() { - // given - final Node leafNode = new Node(8L, "Convertibles", 4, 5); - - // when - final List childNodes = nodeRepository.findChildNodesForNode(leafNode); - - // then - assertThat(childNodes, empty()); - } - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindChildNodesForNode_whenRootNodePassed_nonEmptyListReturned() { - // given - final Node rootNode = new Node(1L, "Vehicles", 1, 18); - - // when - final List childNodes = nodeRepository.findChildNodesForNode(rootNode); - - // then - assertThat(childNodes, contains( - getAllOfMatcher(2L, "Motor Vehicles", 2, 11), - getAllOfMatcher(4L, "Cars", 3, 8), - getAllOfMatcher(8L, "Convertibles", 4, 5), - getAllOfMatcher(9L, "Minivans", 6, 7), - getAllOfMatcher(5L, "Buses", 9, 10), - getAllOfMatcher(3L, "Rail Vehicles", 12, 17), - getAllOfMatcher(6L, "Trains", 13, 14), - getAllOfMatcher(7L, "Trams", 15, 16) - )); - } - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindChildNodesForNode_whenNonExistingNodePassed_emptyListReturned() { - // given - final Node nonExistingNode = new Node(10L, "Some Node"); - - // when - final List childNodes = nodeRepository.findChildNodesForNode(nonExistingNode); - - // then - assertThat(childNodes, empty()); - } - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindNodeDepth_whenLeafNodePassed_correctDepthReturned() { - // given - final Node leafNode = new Node(8L, "Convertibles", 4, 5); - - // when - final int depth = nodeRepository.findNodeDepth(leafNode); - - // then - assertThat(depth, is(3)); - } - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindNodeDepth_whenRootNodePassed_zeroDepthReturned() { - // given - final Node rootNode = new Node(1L, "Vehicles", 1, 18); - - // when - final int depth = nodeRepository.findNodeDepth(rootNode); - - // then - assertThat(depth, is(0)); - } - - @Test - @DatabaseSetup("classpath:datasets/NodeRepository-initialData.xml") - public void testFindNodeDepth_whenNonExistingNodePassed_zeroDepthReturned() { - // given - final Node nonExistingNode = new Node(10L, "Some Node"); - - // when - final int depth = nodeRepository.findNodeDepth(nonExistingNode); - - // then - assertThat(depth, is(0)); - } - - private Matcher getAllOfMatcher(final Long id, final String value, final Integer left, final Integer right) { - return allOf( - hasProperty("id", is(id)), - hasProperty("value", is(value)), - hasProperty("left", is(left)), - hasProperty("right", is(right)) - ); - } -} \ No newline at end of file diff --git a/src/test/java/dev/kotryos/sqlhierarchy/nestedset/MySqlNestedSetNodeRepositoryTest.java b/src/test/java/dev/kotryos/sqlhierarchy/nestedset/MySqlNestedSetNodeRepositoryTest.java new file mode 100644 index 0000000..4e49c0e --- /dev/null +++ b/src/test/java/dev/kotryos/sqlhierarchy/nestedset/MySqlNestedSetNodeRepositoryTest.java @@ -0,0 +1,22 @@ +package dev.kotryos.sqlhierarchy.nestedset; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +class MySqlNestedSetNodeRepositoryTest extends NestedSetNodeRepositoryContract { + + @Container + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.0.36"); + + @DynamicPropertySource + static void registerDataSourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("spring.datasource.driver-class-name", MYSQL::getDriverClassName); + } +} diff --git a/src/test/java/dev/kotryos/sqlhierarchy/nestedset/NestedSetNodeRepositoryContract.java b/src/test/java/dev/kotryos/sqlhierarchy/nestedset/NestedSetNodeRepositoryContract.java new file mode 100644 index 0000000..5c2543a --- /dev/null +++ b/src/test/java/dev/kotryos/sqlhierarchy/nestedset/NestedSetNodeRepositoryContract.java @@ -0,0 +1,171 @@ +package dev.kotryos.sqlhierarchy.nestedset; + +import org.jooq.DSLContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static dev.kotryos.sqlhierarchy.jooq.Tables.NESTED_SET_NODE; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +abstract class NestedSetNodeRepositoryContract { + + private static final long ROOT_ID = 1L; + private static final long LEAF_ID = 8L; + private static final long NON_EXISTING_ID = 10L; + + @Autowired + private DSLContext dsl; + + @Autowired + private NestedSetNodeRepository nodeRepository; + + @BeforeEach + void clearNodes() { + dsl.deleteFrom(NESTED_SET_NODE).execute(); + } + + @Test + void findLeafNodesWhenNodesExistReturnsLeafNodes() { + insertInitialData(); + + List leafNodes = nodeRepository.findLeafNodes(); + + assertThat(leafNodes).containsExactlyInAnyOrder( + new NestedSetNode(8L, "Convertibles", 4, 5), + new NestedSetNode(9L, "Minivans", 6, 7), + new NestedSetNode(5L, "Buses", 9, 10), + new NestedSetNode(6L, "Trains", 13, 14), + new NestedSetNode(7L, "Trams", 15, 16) + ); + } + + @Test + void findLeafNodesWhenNoNodesExistReturnsEmptyList() { + List leafNodes = nodeRepository.findLeafNodes(); + + assertThat(leafNodes).isEmpty(); + } + + @Test + void findPathWhenLeafNodeIdPassedReturnsPath() { + insertInitialData(); + + List path = nodeRepository.findPath(LEAF_ID); + + assertThat(path).containsExactly( + new NestedSetNode(1L, "Vehicles", 1, 18), + new NestedSetNode(2L, "Motor Vehicles", 2, 11), + new NestedSetNode(4L, "Cars", 3, 8) + ); + } + + @Test + void findPathWhenRootNodeIdPassedReturnsEmptyList() { + insertInitialData(); + + List path = nodeRepository.findPath(ROOT_ID); + + assertThat(path).isEmpty(); + } + + @Test + void findPathWhenNonExistingNodeIdPassedReturnsEmptyList() { + insertInitialData(); + + List path = nodeRepository.findPath(NON_EXISTING_ID); + + assertThat(path).isEmpty(); + } + + @Test + void findSubtreeWhenLeafNodeIdPassedReturnsEmptyList() { + insertInitialData(); + + List subtree = nodeRepository.findSubtree(LEAF_ID); + + assertThat(subtree).isEmpty(); + } + + @Test + void findSubtreeWhenRootNodeIdPassedReturnsFlattenedSubtree() { + insertInitialData(); + + List subtree = nodeRepository.findSubtree(ROOT_ID); + + assertThat(subtree).containsExactly( + new NestedSetNode(2L, "Motor Vehicles", 2, 11), + new NestedSetNode(4L, "Cars", 3, 8), + new NestedSetNode(8L, "Convertibles", 4, 5), + new NestedSetNode(9L, "Minivans", 6, 7), + new NestedSetNode(5L, "Buses", 9, 10), + new NestedSetNode(3L, "Rail Vehicles", 12, 17), + new NestedSetNode(6L, "Trains", 13, 14), + new NestedSetNode(7L, "Trams", 15, 16) + ); + } + + @Test + void findSubtreeWhenNonExistingNodeIdPassedReturnsEmptyList() { + insertInitialData(); + + List subtree = nodeRepository.findSubtree(NON_EXISTING_ID); + + assertThat(subtree).isEmpty(); + } + + @Test + void findDepthWhenLeafNodeIdPassedReturnsDepth() { + insertInitialData(); + + int depth = nodeRepository.findDepth(LEAF_ID); + + assertThat(depth).isEqualTo(3); + } + + @Test + void findDepthWhenRootNodeIdPassedReturnsZero() { + insertInitialData(); + + int depth = nodeRepository.findDepth(ROOT_ID); + + assertThat(depth).isZero(); + } + + @Test + void findDepthWhenNonExistingNodeIdPassedReturnsZero() { + insertInitialData(); + + int depth = nodeRepository.findDepth(NON_EXISTING_ID); + + assertThat(depth).isZero(); + } + + private void insertInitialData() { + insertNode(1, "Vehicles", 1, 18); + insertNode(2, "Motor Vehicles", 2, 11); + insertNode(3, "Rail Vehicles", 12, 17); + insertNode(4, "Cars", 3, 8); + insertNode(5, "Buses", 9, 10); + insertNode(6, "Trains", 13, 14); + insertNode(7, "Trams", 15, 16); + insertNode(8, "Convertibles", 4, 5); + insertNode(9, "Minivans", 6, 7); + } + + private void insertNode(long id, String label, int left, int right) { + dsl.insertInto(NESTED_SET_NODE) + .columns( + NESTED_SET_NODE.ID, + NESTED_SET_NODE.LABEL, + NESTED_SET_NODE.LFT, + NESTED_SET_NODE.RGT + ) + .values(id, label, left, right) + .execute(); + } +} diff --git a/src/test/java/dev/kotryos/sqlhierarchy/nestedset/PostgresNestedSetNodeRepositoryTest.java b/src/test/java/dev/kotryos/sqlhierarchy/nestedset/PostgresNestedSetNodeRepositoryTest.java new file mode 100644 index 0000000..77c4795 --- /dev/null +++ b/src/test/java/dev/kotryos/sqlhierarchy/nestedset/PostgresNestedSetNodeRepositoryTest.java @@ -0,0 +1,22 @@ +package dev.kotryos.sqlhierarchy.nestedset; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +class PostgresNestedSetNodeRepositoryTest extends NestedSetNodeRepositoryContract { + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void registerDataSourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName); + } +} diff --git a/src/test/resources/datasets/NodeRepository-initialData.xml b/src/test/resources/datasets/NodeRepository-initialData.xml deleted file mode 100644 index ded3e6b..0000000 --- a/src/test/resources/datasets/NodeRepository-initialData.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/test/resources/test-jpa-context.xml b/src/test/resources/test-jpa-context.xml deleted file mode 100644 index 7190f50..0000000 --- a/src/test/resources/test-jpa-context.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - org.hibernate.dialect.HSQLDialect - create - true - UTF-8 - UTF-8 - - - - - - - - - - - - - - \ No newline at end of file