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
[](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