diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 779a587..0c8fe7d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,10 +22,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: 'temurin' diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index b9dc0b4..e6c8d64 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -19,10 +19,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: 'temurin' diff --git a/jdbc/pom.xml b/jdbc/pom.xml index ed83a57..9984f39 100644 --- a/jdbc/pom.xml +++ b/jdbc/pom.xml @@ -15,12 +15,13 @@ pom - 2.0.2 - 1.7.25 + 2.0.6 + 1.7.36 basic-example + spring-data-jpa-v5 @@ -38,4 +39,16 @@ + + + + jdk17-examples + + [17 + + + spring-data-jpa + + + \ No newline at end of file diff --git a/jdbc/spring-data-jpa-v5/pom.xml b/jdbc/spring-data-jpa-v5/pom.xml new file mode 100644 index 0000000..86c1e36 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/pom.xml @@ -0,0 +1,147 @@ + + 4.0.0 + + tech.ydb.jdbc.examples + ydb-jdbc-examples + 1.1.0-SNAPSHOT + + + spring-data-jpa-v5 + Spring Data JPA Example Hibernate 5 + Basic example for SpringBoot3 and Hibernate 6 + + 1.9.22 + 0.9.1 + 2.5.7 + + + + org.springframework.boot + spring-boot-starter-data-jpa + ${spring.boot.version} + + + org.springframework.data + spring-data-commons + ${spring.boot.version} + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + tech.ydb.dialects + hibernate-ydb-dialect-v5 + ${hibernate.ydb.dialect.version} + + + tech.ydb.jdbc + ydb-jdbc-driver-shaded + + + com.github.javafaker + javafaker + 1.0.2 + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + 1.7.3 + + + org.postgresql + postgresql + 42.7.1 + + + org.testcontainers + postgresql + 1.19.1 + test + + + tech.ydb.test + ydb-junit5-support + test + + + org.junit.jupiter + junit-jupiter-api + + + + + org.springframework.boot + spring-boot-starter-test + ${spring.boot.version} + test + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + + test-compile + + test-compile + + + + + + -Xjsr305=strict + + + spring + jpa + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-maven-noarg + ${kotlin.version} + + + + + + \ No newline at end of file diff --git a/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/Author.kt b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/Author.kt new file mode 100644 index 0000000..8690d96 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/Author.kt @@ -0,0 +1,17 @@ +package tech.ydb.jpa.pagination + +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.Table + +@Entity +@Table(name = "authors") +class Author { + + @Id + lateinit var id: String + + lateinit var firstName: String + + lateinit var lastName: String +} diff --git a/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/Book.kt b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/Book.kt new file mode 100644 index 0000000..54af00f --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/Book.kt @@ -0,0 +1,19 @@ +package tech.ydb.jpa.pagination + +import java.util.* +import javax.persistence.* + +@Entity +@Table(name = "books") +class Book { + + @Id + lateinit var id: String + + lateinit var title: String + lateinit var isbn10: String + lateinit var publicationDate: Date + + @ManyToOne + lateinit var author: Author +} diff --git a/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/BookRepository.kt b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/BookRepository.kt new file mode 100644 index 0000000..67be22d --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/BookRepository.kt @@ -0,0 +1,39 @@ +package tech.ydb.jpa.pagination + +import org.springframework.data.domain.* +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param + +interface BookRepository : CrudRepository { + + /** + * Uses an offset based pagination that first sorts the entries by their [ publication_date][Book.getPublicationDate] + * and then limits the result by dropping the number of rows specified in the + * [offset][Pageable.getOffset] clause. To retrieve [Page.getTotalElements] an additional count query + * is executed. + * + * @param title + * @param pageable + */ + @Query( + "SELECT * FROM books WHERE books.title LIKE %:title% ORDER BY books.publication_date", + countQuery = "SELECT count(*) FROM books WHERE books.title LIKE %:title%", + nativeQuery = true + ) + fun findByTitleContainsOrderByPublicationDate(@Param("title") title: String, pageable: Pageable): Page + + /** + * Uses an offset based slicing that first sorts the entries by their [ publication_date][Book.getPublicationDate] + * and then limits the result by dropping the number of rows specified in the + * [offset][Pageable.getOffset] clause. + * + * @param title + * @param pageable + */ + @Query( + "SELECT * FROM books WHERE books.title LIKE %:title% ORDER BY books.publication_date", + nativeQuery = true + ) + fun findBooksByTitleContainsOrderByPublicationDate(title: String, pageable: Pageable): Slice +} diff --git a/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/PagingApplication.kt b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/PagingApplication.kt new file mode 100644 index 0000000..89f961d --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/pagination/PagingApplication.kt @@ -0,0 +1,6 @@ +package tech.ydb.jpa.pagination + +import org.springframework.boot.autoconfigure.SpringBootApplication + +@SpringBootApplication +class PagingApplication diff --git a/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/simple/SimpleUserRepository.kt b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/simple/SimpleUserRepository.kt new file mode 100644 index 0000000..a2e9ff4 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/simple/SimpleUserRepository.kt @@ -0,0 +1,124 @@ +package tech.ydb.jpa.simple; + +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.scheduling.annotation.Async +import java.util.concurrent.CompletableFuture +import java.util.stream.Stream + +/** + * Simple repository interface for {@link User} instances. The interface is used to declare the so-called query methods, + * i.e. methods to retrieve single entities or collections of them. + */ +interface SimpleUserRepository : CrudRepository { + + /** + * Find the user with the given username. This method will be translated into a query using the + * {@link jakarta.persistence.NamedQuery} annotation at the {@link User} class. + * + * @param username + */ + fun findByTheUsersName(username: String): User + + /** + * Uses {@link Optional} as return and parameter type. + * + * @param username + */ + fun findByUsername(username: String?): User? + + /** + * Find all users with the given lastname. This method will be translated into a query by constructing it directly + * from the method name as there is no other query declared. + * + * @param lastname + */ + fun findByLastname(lastname: String): List + + + /** + * Returns all users with the given firstname. This method will be translated into a query using the one declared in + * the {@link Query} annotation declared one. + * + * @param firstname + */ + @Query("select u from User u where u.firstname = :firstname") + fun findByFirstname(firstname: String): List + + + /** + * Returns all users with the given name as first- or lastname. This makes the query to method relation much more + * refactoring-safe as the order of the method parameters is completely irrelevant. + * + * @param name + */ + @Query("select u from User u where u.firstname = :name or u.lastname = :name") + fun findByFirstnameOrLastname(name: String): List + + /** + * Returns the total number of entries deleted as their lastnames match the given one. + * + * @param lastname + * @return + */ + fun removeByLastname(lastname: String): Long + + /** + * Returns a {@link Slice} counting a maximum number of {@link Pageable#getPageSize()} users matching given criteria + * starting at {@link Pageable#getOffset()} without prior count of the total number of elements available. + * + * @param lastname + * @param page + */ + @Query( + "SELECT * FROM users WHERE users.lastname = :lastname ORDER BY users.username", + nativeQuery = true + ) + fun findByLastnameOrderByUsernameAsc(lastname: String, page: Pageable): Slice + + /** + * Return the first 2 users ordered by their lastname asc. + * + *
+	 * Example for findFirstK / findTopK functionality.
+	 * 
+ */ + @Query("SELECT * FROM users ORDER BY lastname LIMIT 2", nativeQuery = true) + fun findFirst2ByOrderByLastnameAsc(): List + + + /** + * Return all the users with the given firstname or lastname. Makes use of SpEL (Spring Expression Language). + * + * @param user + */ + @Query("select u from User u where u.firstname = :#{#user.firstname} or u.lastname = :#{#user.lastname}") + fun findByFirstnameOrLastname(user: User): Iterable + + /** + * Sample default method. + * + * @param user + */ + fun findByLastname(user: User): List { + return findByLastname(user.lastname); + } + + /** + * Sample method to demonstrate support for {@link Stream} as a return type with a custom query. The query is executed + * in a streaming fashion which means that the method returns as soon as the first results are ready. + */ + @Query("select u from User u") + fun streamAllCustomers(): Stream + + /** + * Sample method to demonstrate support for {@link Stream} as a return type with a derived query. The query is + * executed in a streaming fashion which means that the method returns as soon as the first results are ready. + */ + fun findAllByLastnameIsNotNull(): Stream + + @Async + fun readAllBy(): CompletableFuture> +} diff --git a/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/simple/User.kt b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/simple/User.kt new file mode 100644 index 0000000..676b7b5 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/simple/User.kt @@ -0,0 +1,45 @@ +package tech.ydb.jpa.simple + +import javax.persistence.* +import org.springframework.data.util.ProxyUtils + +@Entity +@Table(name = "Users", indexes = [Index(name = "username_index", columnList = "username")]) +@NamedQuery(name = "User.findByTheUsersName", query = "from User u where u.username = ?1") +class User { + + @Id + var id: Long = 0 + + lateinit var username: String + + lateinit var firstname: String + + lateinit var lastname: String + + override fun equals(other: Any?): Boolean { + if (null == other) { + return false + } + + if (this === other) { + return true + } + + if (javaClass != ProxyUtils.getUserClass(other)) { + return false + } + + val that: User = other as User + + return this.id == that.id + } + + override fun hashCode(): Int { + var hashCode = 17 + + hashCode += id.hashCode() * 31 + + return hashCode + } +} diff --git a/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/simple/UserApplication.kt b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/simple/UserApplication.kt new file mode 100644 index 0000000..5b745c4 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/main/kotlin/tech/ydb/jpa/simple/UserApplication.kt @@ -0,0 +1,12 @@ +package tech.ydb.jpa.simple + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.scheduling.annotation.EnableAsync + +/** + * @author Kirill Kurdyukov + */ +@EnableAsync +@SpringBootApplication +class UserApplication \ No newline at end of file diff --git a/jdbc/spring-data-jpa-v5/src/main/resources/application-postgres.properties b/jdbc/spring-data-jpa-v5/src/main/resources/application-postgres.properties new file mode 100644 index 0000000..fbba779 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/main/resources/application-postgres.properties @@ -0,0 +1 @@ +spring.datasource.driver-class-name=org.postgresql.Driver \ No newline at end of file diff --git a/jdbc/spring-data-jpa-v5/src/main/resources/application-ydb.properties b/jdbc/spring-data-jpa-v5/src/main/resources/application-ydb.properties new file mode 100644 index 0000000..73bd993 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/main/resources/application-ydb.properties @@ -0,0 +1,4 @@ +spring.jpa.properties.hibernate.dialect=tech.ydb.hibernate.dialect.YdbDialect + +spring.datasource.driver-class-name=tech.ydb.jdbc.YdbDriver +spring.datasource.url=jdbc:ydb:grpc://localhost:2136/local diff --git a/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/PostgresDockerTest.kt b/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/PostgresDockerTest.kt new file mode 100644 index 0000000..fa7bb09 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/PostgresDockerTest.kt @@ -0,0 +1,33 @@ +package tech.ydb.jpa + +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.containers.wait.strategy.Wait + +/** + * Debug mode + * + * @author Kirill Kurdyukov + */ +@ActiveProfiles("test", "postgres") +abstract class PostgresDockerTest { + + companion object { + private val postgresContainer: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:latest") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test") + .waitingFor(Wait.forListeningPort()) + + @JvmStatic + @DynamicPropertySource + fun prepareProperties(registry: DynamicPropertyRegistry) { + postgresContainer.start() + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl) + registry.add("spring.datasource.password", postgresContainer::getPassword) + registry.add("spring.datasource.username", postgresContainer::getUsername) + } + } +} \ No newline at end of file diff --git a/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/YdbDockerTest.kt b/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/YdbDockerTest.kt new file mode 100644 index 0000000..4b743b4 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/YdbDockerTest.kt @@ -0,0 +1,29 @@ +package tech.ydb.jpa + +import org.junit.jupiter.api.extension.RegisterExtension +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import tech.ydb.test.junit5.YdbHelperExtension + +/** + * @author Kirill Kurdyukov + */ +@ActiveProfiles("test", "ydb") +abstract class YdbDockerTest { + + companion object { + @JvmField + @RegisterExtension + val ydb = YdbHelperExtension() + + @JvmStatic + @DynamicPropertySource + fun propertySource(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { + "jdbc:ydb:${if (ydb.useTls()) "grpcs://" else "grpc://"}" + + "${ydb.endpoint()}${ydb.database()}${ydb.authToken()?.let { "?token=$it" } ?: ""}" + } + } + } +} \ No newline at end of file diff --git a/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/pagination/PaginationTests.kt b/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/pagination/PaginationTests.kt new file mode 100644 index 0000000..ffb803f --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/pagination/PaginationTests.kt @@ -0,0 +1,110 @@ +package tech.ydb.jpa.pagination + +import com.github.javafaker.Faker +import org.assertj.core.api.Assertions.assertThat +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 org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.transaction.annotation.Transactional +import tech.ydb.jpa.PostgresDockerTest +import tech.ydb.jpa.YdbDockerTest +import java.util.* +import java.util.concurrent.TimeUnit +import javax.persistence.EntityManager + +/** + * Show different types of paging styles using [Page], [Slice] and [Window]. + * + * @author Christoph Strobl + * @author Mark Paluch + */ +@SpringBootTest +@Transactional +internal class PaginationTests : YdbDockerTest() { + + @Autowired + lateinit var books: BookRepository + + @BeforeEach + fun setUp() { + val faker = Faker() + + val authorList = createAuthors(faker) + createBooks(faker, authorList) + } + + /** + * Page through the results using an offset/limit approach where the server skips over the number of results specified + * via [Pageable.getOffset]. The [Page] return type will run an additional count query to + * read the total number of matching rows on each request. + */ + @Test + fun pageThroughResultsWithSkipAndLimit() { + var page: Page + var pageRequest: Pageable = PageRequest.of(0, 2) + + do { + page = books.findByTitleContainsOrderByPublicationDate("the", pageRequest) + assertThat(page.content.size).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2) + + pageRequest = page.nextPageable() + } while (page.hasNext()) + } + + /** + * Run through the results using an offset/limit approach where the server skips over the number of results specified + * via [Pageable.getOffset]. No additional count query to read the total number of matching rows is + * issued. Still [Slice] requests, but does not emit, one row more than specified via [Page.getSize] to + * feed [Slice.hasNext] + */ + @Test + fun sliceThroughResultsWithSkipAndLimit() { + var slice: Slice + var pageRequest: Pageable = PageRequest.of(0, 2) + + do { + slice = books.findBooksByTitleContainsOrderByPublicationDate("the", pageRequest) + assertThat(slice.content.size).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2) + + pageRequest = slice.nextPageable() + } while (slice.hasNext()) + } + + // --> Test Data + @Autowired + lateinit var em: EntityManager + + private fun createAuthors(faker: Faker): List { + val authors = List(11) { id: Int -> + val author = Author() + author.id = "author-%s".format(id) + author.firstName = faker.name().firstName() + author.lastName = faker.name().lastName() + + em.persist(author) + author + } + + return authors + } + + private fun createBooks(faker: Faker, authors: List): List { + val rand = Random() + return List(100) { id: Int -> + val book = Book() + book.id = "book-%03d".format(id) + book.title = (if (id % 2 == 0) "the-crazy-book-" else "") + faker.book().title() + book.isbn10 = UUID.randomUUID().toString().substring(0, 10) + book.publicationDate = faker.date().past(5000, TimeUnit.DAYS) + book.author = authors[rand.nextInt(authors.size)] + + em.persist(book) + book + } + } +} diff --git a/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/simple/SimpleRepositoryTest.kt b/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/simple/SimpleRepositoryTest.kt new file mode 100644 index 0000000..ccd0eee --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/test/kotlin/tech/ydb/jpa/simple/SimpleRepositoryTest.kt @@ -0,0 +1,200 @@ +package tech.ydb.jpa.simple + +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.* +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 org.springframework.dao.InvalidDataAccessApiUsageException +import org.springframework.data.domain.PageRequest +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import tech.ydb.jpa.YdbDockerTest +import java.util.stream.Collectors + +/** + * @author Kirill Kurdyukov + */ +@SpringBootTest +@Transactional +class SimpleRepositoryTest : YdbDockerTest() { + + @Autowired + lateinit var repository: SimpleUserRepository + + lateinit var user: User + + @BeforeEach + fun setUp() { + user = User().apply { + username = "foobar" + firstname = "firstname" + lastname = "lastname" + } + } + + @Test + fun findSavedUserById() { + user = repository.save(user) + + assertThat(repository.findById(user.id)).hasValue(user) + } + + @Test + fun findSavedUserByLastname() { + user = repository.save(user) + + assertThat(repository.findByLastname("lastname")).contains(user) + } + + @Test + fun findByFirstnameOrLastname() { + user = repository.save(user) + + assertThat(repository.findByFirstnameOrLastname("lastname")).contains(user) + } + + @Test + fun useOptionalAsReturnAndParameterType() { + assertNull(repository.findByUsername("foobar")) + + repository.save(user) + + assertNotNull(repository.findByUsername("foobar")) + } + + @Test + fun removeByLastname() { + // create a 2nd user with the same lastname as user + + val user2 = User().apply { id = 1; lastname = user.lastname } + + // create a 3rd user as control group + val user3 = User().apply { id = 2; lastname = "no-positive-match" } + + repository.saveAll(listOf(user, user2, user3)) + + assertThat(repository.removeByLastname(user.lastname)).isEqualTo(2L) + assertThat(repository.existsById(user3.id)).isTrue() + } + + @Test + fun useSliceToLoadContent() { + val totalNumberUsers = 11 + val source: MutableList = ArrayList(totalNumberUsers) + + for (i in 1..totalNumberUsers) { + val user = User().apply { + id = i.toLong() + lastname = user.lastname + username = "${user.lastname}-${String.format("%03d", i)}" + } + + source.add(user) + } + + repository.saveAll(source) + + val users = repository.findByLastnameOrderByUsernameAsc(user.lastname, PageRequest.of(1, 5)) + + assertThat(users).containsAll(source.subList(5, 10)) + } + + @Test + fun findFirst2ByOrderByLastnameAsc() { + val user0 = User().apply { id = 1; lastname = "lastname-0" } + + val user1 = User().apply { id = 2; lastname = "lastname-1" } + + val user2 = User().apply { id = 3; lastname = "lastname-2" } + + // we deliberately save the items in reverse + repository.saveAll(listOf(user2, user1, user0)) + + val result = repository.findFirst2ByOrderByLastnameAsc() + + assertThat(result).containsExactly(user0, user1) + } + + @Test + fun findByFirstnameOrLastnameUsingSpEL() { + val first = User().apply { id = 1; lastname = "lastname" } + + val second = User().apply { id = 2; firstname = "firstname" } + + val third = User() + + repository.saveAll(listOf(first, second, third)) + + val reference = User().apply { id = 3; firstname = "firstname"; lastname = "lastname" } + + val users = repository.findByFirstnameOrLastname(reference) + + assertThat(users).contains(first) + assertThat(users).contains(second) + assertThat(users).hasSize(2) + } + + /** + * Streaming data from the store by using a repository method that returns a [Stream]. Note, that since the + * resulting [Stream] contains state it needs to be closed explicitly after use! + */ + @Test + fun useJava8StreamsWithCustomQuery() { + val user1 = repository.save(User().apply { id = 1; firstname = "Customer1"; lastname = "Foo" }) + val user2 = repository.save(User().apply { id = 2; firstname = "Customer2"; lastname = "Bar" }) + + repository.streamAllCustomers().use { stream -> + assertThat(stream.collect(Collectors.toList())).contains(user1, user2) + } + } + + /** + * Streaming data from the store by using a repository method that returns a [Stream] with a derived query. + * Note, that since the resulting [Stream] contains state it needs to be closed explicitly after use! + */ + @Test + fun useJava8StreamsWithDerivedQuery() { + val user1 = repository.save(User().apply { id = 1; firstname = "Customer1"; lastname = "Foo" }) + val user2 = repository.save(User().apply { id = 2; firstname = "Customer2"; lastname = "Bar" }) + + repository.findAllByLastnameIsNotNull().use { stream -> + assertThat(stream.collect(Collectors.toList())).contains(user1, user2) + } + } + + /** + * Query methods using streaming need to be used inside a surrounding transaction to keep the connection open while + * the stream is consumed. We simulate that not being the case by actively disabling the transaction here. + */ + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + fun rejectsStreamExecutionIfNoSurroundingTransactionActive() { + Assertions.assertThrows(InvalidDataAccessApiUsageException::class.java) { + repository.findAllByLastnameIsNotNull() + } + } + + /** + * Here we demonstrate the usage of [CompletableFuture] as a result wrapper for asynchronous repository query + * methods. Note, that we need to disable the surrounding transaction to be able to asynchronously read the written + * data from another thread within the same test method. + */ + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + fun supportsCompletableFuturesAsReturnTypeWrapper() { + repository.save(User().apply { id = 1; firstname = "Customer1"; lastname = "Foo" }) + repository.save(User().apply { id = 2; firstname = "Customer2"; lastname = "Bar" }) + + runBlocking { + val users = repository.readAllBy().await() + assertThat(users).hasSize(2) + } + + repository.deleteAll() + } +} \ No newline at end of file diff --git a/jdbc/spring-data-jpa-v5/src/test/resources/application-test.properties b/jdbc/spring-data-jpa-v5/src/test/resources/application-test.properties new file mode 100644 index 0000000..4abc7c5 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/test/resources/application-test.properties @@ -0,0 +1,5 @@ +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=false +spring.jpa.properties.hibernate.highlight_sql=true + +spring.jpa.properties.hibernate.hbm2ddl.auto=create \ No newline at end of file diff --git a/jdbc/spring-data-jpa-v5/src/test/resources/logback.xml b/jdbc/spring-data-jpa-v5/src/test/resources/logback.xml new file mode 100644 index 0000000..77232d4 --- /dev/null +++ b/jdbc/spring-data-jpa-v5/src/test/resources/logback.xml @@ -0,0 +1,19 @@ + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jdbc/spring-data-jpa/pom.xml b/jdbc/spring-data-jpa/pom.xml new file mode 100644 index 0000000..30294b5 --- /dev/null +++ b/jdbc/spring-data-jpa/pom.xml @@ -0,0 +1,149 @@ + + 4.0.0 + + tech.ydb.jdbc.examples + ydb-jdbc-examples + 1.1.0-SNAPSHOT + + + spring-data-jpa + Spring Data JPA Example + Basic example for SpringBoot3 and Hibernate 6 + + 17 + 1.9.22 + 0.9.1 + 3.2.1 + + + + org.springframework.boot + spring-boot-starter-data-jpa + ${spring.boot.version} + + + org.springframework.data + spring-data-commons + ${spring.boot.version} + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + tech.ydb.dialects + hibernate-ydb-dialect + ${hibernate.ydb.dialect.version} + + + tech.ydb.jdbc + ydb-jdbc-driver-shaded + + + com.github.javafaker + javafaker + 1.0.2 + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + 1.7.3 + + + org.postgresql + postgresql + 42.7.1 + + + org.testcontainers + postgresql + 1.19.1 + test + + + + org.springframework.boot + spring-boot-starter-test + ${spring.boot.version} + test + + + tech.ydb.test + ydb-junit5-support + test + + + org.junit.jupiter + junit-jupiter-api + + + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + + test-compile + + test-compile + + + + + + -Xjsr305=strict + + + spring + jpa + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-maven-noarg + ${kotlin.version} + + + + + + \ No newline at end of file diff --git a/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/Author.kt b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/Author.kt new file mode 100644 index 0000000..a0f516b --- /dev/null +++ b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/Author.kt @@ -0,0 +1,17 @@ +package tech.ydb.jpa.pagination + +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "authors") +class Author { + + @Id + lateinit var id: String + + lateinit var firstName: String + + lateinit var lastName: String +} diff --git a/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/Book.kt b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/Book.kt new file mode 100644 index 0000000..3141a48 --- /dev/null +++ b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/Book.kt @@ -0,0 +1,22 @@ +package tech.ydb.jpa.pagination + +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.util.* + +@Entity +@Table(name = "books") +class Book { + + @Id + lateinit var id: String + + lateinit var title: String + lateinit var isbn10: String + lateinit var publicationDate: Date + + @ManyToOne + lateinit var author: Author +} diff --git a/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/BookRepository.kt b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/BookRepository.kt new file mode 100644 index 0000000..1505d66 --- /dev/null +++ b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/BookRepository.kt @@ -0,0 +1,49 @@ +package tech.ydb.jpa.pagination + +import org.springframework.data.domain.* +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.ListCrudRepository +import org.springframework.data.repository.query.Param + +interface BookRepository : ListCrudRepository { + + /** + * Uses an offset based pagination that first sorts the entries by their [ publication_date][Book.getPublicationDate] + * and then limits the result by dropping the number of rows specified in the + * [offset][Pageable.getOffset] clause. To retrieve [Page.getTotalElements] an additional count query + * is executed. + * + * @param title + * @param pageable + */ + @Query( + value = "SELECT * FROM books WHERE title LIKE %:title% ORDER BY books.publication_date", + countQuery = "SELECT count(*) FROM books WHERE title LIKE %:title% ", + nativeQuery = true + ) + fun findByTitleContainsOrderByPublicationDate(@Param("title") title: String, pageable: Pageable): Page + + /** + * Uses an offset based slicing that first sorts the entries by their [ publication_date][Book.getPublicationDate] and then limits the result by dropping the number of rows specified in the + * [offset][Pageable.getOffset] clause. + * + * @param title + * @param pageable + */ + @Query( + value = "SELECT * FROM books WHERE title LIKE %:title% ORDER BY books.publication_date", + nativeQuery = true + ) + fun findBooksByTitleContainsOrderByPublicationDate(title: String, pageable: Pageable): Slice + + /** + * Depending on the provided [ScrollPosition] either [ offset][org.springframework.data.domain.OffsetScrollPosition] + * or [keyset][org.springframework.data.domain.KeysetScrollPosition] scrolling is possible. Scrolling + * through results requires a stable [org.springframework.data.domain.Sort] which is different from what + * [Pageable.getSort] offers. The limit is defined via the Top keyword. + * + * @param title + * @param scrollPosition + */ + fun findTop2ByTitleContainsOrderByPublicationDate(title: String, scrollPosition: ScrollPosition): Window +} diff --git a/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/PagingApplication.kt b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/PagingApplication.kt new file mode 100644 index 0000000..89f961d --- /dev/null +++ b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/pagination/PagingApplication.kt @@ -0,0 +1,6 @@ +package tech.ydb.jpa.pagination + +import org.springframework.boot.autoconfigure.SpringBootApplication + +@SpringBootApplication +class PagingApplication diff --git a/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/simple/SimpleUserRepository.kt b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/simple/SimpleUserRepository.kt new file mode 100644 index 0000000..a877d9c --- /dev/null +++ b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/simple/SimpleUserRepository.kt @@ -0,0 +1,150 @@ +package tech.ydb.jpa.simple; + +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.ListCrudRepository; +import org.springframework.scheduling.annotation.Async; +import java.util.concurrent.CompletableFuture +import java.util.stream.Stream + +/** + * Simple repository interface for {@link User} instances. The interface is used to declare the so-called query methods, + * i.e. methods to retrieve single entities or collections of them. + */ +interface SimpleUserRepository : ListCrudRepository { + + /** + * Find the user with the given username. This method will be translated into a query using the + * {@link jakarta.persistence.NamedQuery} annotation at the {@link User} class. + * + * @param username + */ + fun findByTheUsersName(username: String): User + + /** + * Uses {@link Optional} as return and parameter type. + * + * @param username + */ + fun findByUsername(username: String?): User? + + /** + * Find all users with the given lastname. This method will be translated into a query by constructing it directly + * from the method name as there is no other query declared. + * + * @param lastname + */ + fun findByLastname(lastname: String): List + + /** + * Find at most the number of users defined via maxResults with the given lastname. + * This method will be translated into a query by constructing it directly from the method name as there is no other + * query declared. + * + * @param lastname + * @param maxResults the maximum number of results returned. + */ + fun findByLastname(lastname: String, maxResults: Limit): List + + /** + * Returns all users with the given firstname. This method will be translated into a query using the one declared in + * the {@link Query} annotation declared one. + * + * @param firstname + */ + @Query("select u from User u where u.firstname = :firstname") + fun findByFirstname(firstname: String): List + + /** + * Returns at most the number of users defined via {@link Limit} with the given firstname. This method will be + * translated into a query using the one declared in the {@link Query} annotation declared one. + * + * @param firstname + * @param maxResults the maximum number of results returned. + */ + @Query("select u from User u where u.firstname = :firstname") + fun findByFirstname(firstname: String, maxResults: Limit): List + + /** + * Returns all users with the given name as first- or lastname. This makes the query to method relation much more + * refactoring-safe as the order of the method parameters is completely irrelevant. + * + * @param name + */ + @Query("select u from User u where u.firstname = :name or u.lastname = :name") + fun findByFirstnameOrLastname(name: String): List + + /** + * Returns the total number of entries deleted as their lastnames match the given one. + * + * @param lastname + * @return + */ + fun removeByLastname(lastname: String): Long + + /** + * Returns a {@link Slice} counting a maximum number of {@link Pageable#getPageSize()} users matching given criteria + * starting at {@link Pageable#getOffset()} without prior count of the total number of elements available. + * + * @param lastname + * @param page + */ + fun findByLastnameOrderByUsernameAsc(lastname: String, page: Pageable): Slice + + /** + * Return the first 2 users ordered by their lastname asc. + * + *
+	 * Example for findFirstK / findTopK functionality.
+	 * 
+ */ + fun findFirst2ByOrderByLastnameAsc(): List + + /** + * Return the first 2 users ordered by the given {@code sort} definition. + * + *
+	 * This variant is very flexible because one can ask for the first K results when a ASC ordering
+	 * is used as well as for the last K results when a DESC ordering is used.
+	 * 
+ * + * @param sort + */ + fun findTop2By(sort: Sort): List + + /** + * Return all the users with the given firstname or lastname. Makes use of SpEL (Spring Expression Language). + * + * @param user + */ + @Query("select u from User u where u.firstname = :#{#user.firstname} or u.lastname = :#{#user.lastname}") + fun findByFirstnameOrLastname(user: User): Iterable + + /** + * Sample default method. + * + * @param user + */ + fun findByLastname(user: User): List { + return findByLastname(user.lastname); + } + + /** + * Sample method to demonstrate support for {@link Stream} as a return type with a custom query. The query is executed + * in a streaming fashion which means that the method returns as soon as the first results are ready. + */ + @Query("select u from User u") + fun streamAllCustomers(): Stream + + /** + * Sample method to demonstrate support for {@link Stream} as a return type with a derived query. The query is + * executed in a streaming fashion which means that the method returns as soon as the first results are ready. + */ + fun findAllByLastnameIsNotNull(): Stream + + @Async + fun readAllBy(): CompletableFuture> +} diff --git a/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/simple/User.kt b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/simple/User.kt new file mode 100644 index 0000000..69f44ab --- /dev/null +++ b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/simple/User.kt @@ -0,0 +1,57 @@ +package tech.ydb.jpa.simple + +import jakarta.persistence.* +import org.hibernate.annotations.GenericGenerator +import org.hibernate.engine.spi.SharedSessionContractImplementor +import org.hibernate.id.IdentifierGenerator +import org.springframework.data.util.ProxyUtils +import java.util.concurrent.ThreadLocalRandom + +@Entity +@Table(name = "Users", indexes = [Index(name = "username_index", columnList = "username")]) +@NamedQuery(name = "User.findByTheUsersName", query = "from User u where u.username = ?1") +class User { + + @Id + @GeneratedValue(generator = "random-int-id") + @GenericGenerator(name = "random-int-id", type = RandomLongGenerator::class) + var id: Long = 0 + + lateinit var username: String + + lateinit var firstname: String + + lateinit var lastname: String + + class RandomLongGenerator : IdentifierGenerator { + override fun generate(session: SharedSessionContractImplementor, `object`: Any): Any { + return ThreadLocalRandom.current().nextLong() + } + } + + override fun equals(other: Any?): Boolean { + if (null == other) { + return false + } + + if (this === other) { + return true + } + + if (javaClass != ProxyUtils.getUserClass(other)) { + return false + } + + val that: User = other as User + + return this.id == that.id + } + + override fun hashCode(): Int { + var hashCode = 17 + + hashCode += id.hashCode() * 31 + + return hashCode + } +} diff --git a/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/simple/UserApplication.kt b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/simple/UserApplication.kt new file mode 100644 index 0000000..35f39d0 --- /dev/null +++ b/jdbc/spring-data-jpa/src/main/kotlin/tech/ydb/jpa/simple/UserApplication.kt @@ -0,0 +1,11 @@ +package tech.ydb.jpa.simple + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.scheduling.annotation.EnableAsync + +/** + * @author Kirill Kurdyukov + */ +@EnableAsync +@SpringBootApplication +class UserApplication \ No newline at end of file diff --git a/jdbc/spring-data-jpa/src/main/resources/application-postgres.properties b/jdbc/spring-data-jpa/src/main/resources/application-postgres.properties new file mode 100644 index 0000000..fbba779 --- /dev/null +++ b/jdbc/spring-data-jpa/src/main/resources/application-postgres.properties @@ -0,0 +1 @@ +spring.datasource.driver-class-name=org.postgresql.Driver \ No newline at end of file diff --git a/jdbc/spring-data-jpa/src/main/resources/application-ydb.properties b/jdbc/spring-data-jpa/src/main/resources/application-ydb.properties new file mode 100644 index 0000000..73bd993 --- /dev/null +++ b/jdbc/spring-data-jpa/src/main/resources/application-ydb.properties @@ -0,0 +1,4 @@ +spring.jpa.properties.hibernate.dialect=tech.ydb.hibernate.dialect.YdbDialect + +spring.datasource.driver-class-name=tech.ydb.jdbc.YdbDriver +spring.datasource.url=jdbc:ydb:grpc://localhost:2136/local diff --git a/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/PostgresDockerTest.kt b/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/PostgresDockerTest.kt new file mode 100644 index 0000000..fa7bb09 --- /dev/null +++ b/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/PostgresDockerTest.kt @@ -0,0 +1,33 @@ +package tech.ydb.jpa + +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.containers.wait.strategy.Wait + +/** + * Debug mode + * + * @author Kirill Kurdyukov + */ +@ActiveProfiles("test", "postgres") +abstract class PostgresDockerTest { + + companion object { + private val postgresContainer: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:latest") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test") + .waitingFor(Wait.forListeningPort()) + + @JvmStatic + @DynamicPropertySource + fun prepareProperties(registry: DynamicPropertyRegistry) { + postgresContainer.start() + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl) + registry.add("spring.datasource.password", postgresContainer::getPassword) + registry.add("spring.datasource.username", postgresContainer::getUsername) + } + } +} \ No newline at end of file diff --git a/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/YdbDockerTest.kt b/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/YdbDockerTest.kt new file mode 100644 index 0000000..4b743b4 --- /dev/null +++ b/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/YdbDockerTest.kt @@ -0,0 +1,29 @@ +package tech.ydb.jpa + +import org.junit.jupiter.api.extension.RegisterExtension +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import tech.ydb.test.junit5.YdbHelperExtension + +/** + * @author Kirill Kurdyukov + */ +@ActiveProfiles("test", "ydb") +abstract class YdbDockerTest { + + companion object { + @JvmField + @RegisterExtension + val ydb = YdbHelperExtension() + + @JvmStatic + @DynamicPropertySource + fun propertySource(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { + "jdbc:ydb:${if (ydb.useTls()) "grpcs://" else "grpc://"}" + + "${ydb.endpoint()}${ydb.database()}${ydb.authToken()?.let { "?token=$it" } ?: ""}" + } + } + } +} \ No newline at end of file diff --git a/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/pagination/PaginationTests.kt b/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/pagination/PaginationTests.kt new file mode 100644 index 0000000..c8ba42d --- /dev/null +++ b/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/pagination/PaginationTests.kt @@ -0,0 +1,168 @@ +package tech.ydb.jpa.pagination + +import com.github.javafaker.Faker +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +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 org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Window +import org.springframework.data.domain.ScrollPosition +import org.springframework.data.domain.Slice +import org.springframework.data.support.WindowIterator +import org.springframework.transaction.annotation.Transactional +import tech.ydb.jpa.PostgresDockerTest +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * Show different types of paging styles using [Page], [Slice] and [Window]. + * + * @author Christoph Strobl + * @author Mark Paluch + */ +@SpringBootTest +@Transactional +internal class PaginationTests : PostgresDockerTest() /* TODO ESCAPE '\' don't support */{ + + @Autowired + lateinit var books: BookRepository + + @BeforeEach + fun setUp() { + val faker = Faker() + + val authorList = createAuthors(faker) + createBooks(faker, authorList) + } + + /** + * Page through the results using an offset/limit approach where the server skips over the number of results specified + * via [Pageable.getOffset]. The [Page] return type will run an additional count query to + * read the total number of matching rows on each request. + */ + @Test + fun pageThroughResultsWithSkipAndLimit() { + var page: Page + var pageRequest: Pageable = PageRequest.of(0, 2) + + do { + page = books.findByTitleContainsOrderByPublicationDate("the", pageRequest) + assertThat(page.content.size).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2) + + pageRequest = page.nextPageable() + } while (page.hasNext()) + } + + /** + * Run through the results using an offset/limit approach where the server skips over the number of results specified + * via [Pageable.getOffset]. No additional count query to read the total number of matching rows is + * issued. Still [Slice] requests, but does not emit, one row more than specified via [Page.getSize] to + * feed [Slice.hasNext] + */ + @Test + fun sliceThroughResultsWithSkipAndLimit() { + var slice: Slice + var pageRequest: Pageable = PageRequest.of(0, 2) + + do { + slice = books.findBooksByTitleContainsOrderByPublicationDate("the", pageRequest) + assertThat(slice.content.size).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2) + + pageRequest = slice.nextPageable() + } while (slice.hasNext()) + } + + /** + * Scroll through the results using an offset/limit approach where the server skips over the number of results + * specified via [OffsetScrollPosition.getOffset]. + * + * + * This approach is similar to the [slicing one][.sliceThroughResultsWithSkipAndLimit]. + */ + @Test + fun scrollThroughResultsWithSkipAndLimit() { + var window: Window + var scrollPosition: ScrollPosition = ScrollPosition.offset() + + do { + window = books.findTop2ByTitleContainsOrderByPublicationDate("the", scrollPosition) + assertThat(window.content.size).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2) + + scrollPosition = window.positionAt(window.content.size - 1) + } while (window.hasNext()) + } + + /** + * Scroll through the results using an offset/limit approach where the server skips over the number of results + * specified via [OffsetScrollPosition.getOffset] using [WindowIterator]. + * + * + * This approach is similar to the [slicing one][.sliceThroughResultsWithSkipAndLimit]. + */ + @Test + fun scrollThroughResultsUsingWindowIteratorWithSkipAndLimit() { + val iterator: WindowIterator = WindowIterator + .of { scrollPosition: ScrollPosition -> books.findTop2ByTitleContainsOrderByPublicationDate("the-crazy-book-", scrollPosition) } + .startingAt(ScrollPosition.offset()) + + val allBooks: List = iterator.asSequence().toList() + + assertThat(allBooks).hasSize(50) + } + + /** + * Scroll through the results using an index based approach where the [keyset][KeysetScrollPosition.getKeys] + * keeps track of already seen values to resume scrolling by altering the where clause to only return rows after the + * values contained in the keyset. Set logging.level.org.hibernate.SQL=debug to show the modified query in + * the log. + */ + @Test + fun scrollThroughResultsWithKeyset() { + var window: Window + var scrollPosition: ScrollPosition = ScrollPosition.keyset() + do { + window = books.findTop2ByTitleContainsOrderByPublicationDate("the", scrollPosition) + assertThat(window.content.size).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2) + + scrollPosition = window.positionAt(window.content.size - 1) + } while (window.hasNext()) + } + + // --> Test Data + @Autowired + lateinit var em: EntityManager + + private fun createAuthors(faker: Faker): List { + val authors = List(11) { id: Int -> + val author = Author() + author.id = "author-%s".format(id) + author.firstName = faker.name().firstName() + author.lastName = faker.name().lastName() + + em.persist(author) + author + } + + return authors + } + + private fun createBooks(faker: Faker, authors: List): List { + val rand = Random() + return List(100) { id: Int -> + val book = Book() + book.id = "book-%03d".format(id) + book.title = (if (id % 2 == 0) "the-crazy-book-" else "") + faker.book().title() + book.isbn10 = UUID.randomUUID().toString().substring(0, 10) + book.publicationDate = faker.date().past(5000, TimeUnit.DAYS) + book.author = authors[rand.nextInt(authors.size)] + + em.persist(book) + book + } + } +} diff --git a/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/simple/SimpleRepositoryTest.kt b/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/simple/SimpleRepositoryTest.kt new file mode 100644 index 0000000..0821c50 --- /dev/null +++ b/jdbc/spring-data-jpa/src/test/kotlin/tech/ydb/jpa/simple/SimpleRepositoryTest.kt @@ -0,0 +1,236 @@ +package tech.ydb.jpa.simple + +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +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 org.springframework.dao.InvalidDataAccessApiUsageException +import org.springframework.data.domain.Limit +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import tech.ydb.jpa.YdbDockerTest +import java.util.stream.Collectors + +/** + * @author Kirill Kurdyukov + */ +@SpringBootTest +@Transactional +class SimpleRepositoryTest : YdbDockerTest() { + + @Autowired + lateinit var repository: SimpleUserRepository + + lateinit var user: User + + @BeforeEach + fun setUp() { + user = User().apply { + username = "foobar" + firstname = "firstname" + lastname = "lastname" + } + } + + @Test + fun findSavedUserById() { + user = repository.save(user) + + assertThat(repository.findById(user.id)).hasValue(user) + } + + @Test + fun findSavedUserByLastname() { + user = repository.save(user) + + assertThat(repository.findByLastname("lastname")).contains(user) + } + + @Test + fun findLimitedNumberOfUsersViaDerivedQuery() { + (0..10).forEach { _ -> repository.save(User().apply { lastname = "lastname" }) } + + assertThat(repository.findByLastname("lastname", Limit.of(5))).hasSize(5) + } + + @Test + fun findLimitedNumberOfUsersViaAnnotatedQuery() { + (0..10).forEach { _ -> repository.save(User().apply { firstname = "firstname" }) } + + assertThat(repository.findByFirstname("firstname", Limit.of(5))).hasSize(5) + } + + @Test + fun findByFirstnameOrLastname() { + user = repository.save(user) + + assertThat(repository.findByFirstnameOrLastname("lastname")).contains(user) + } + + @Test + fun useOptionalAsReturnAndParameterType() { + assertNull(repository.findByUsername("foobar")) + + repository.save(user) + + assertNotNull(repository.findByUsername("foobar")) + } + + @Test + fun removeByLastname() { + // create a 2nd user with the same lastname as user + + val user2 = User().apply { lastname = user.lastname } + + // create a 3rd user as control group + val user3 = User().apply { lastname = "no-positive-match" } + + repository.saveAll(listOf(user, user2, user3)) + + assertThat(repository.removeByLastname(user.lastname)).isEqualTo(2L) + assertThat(repository.existsById(user3.id)).isTrue() + } + + @Test + fun useSliceToLoadContent() { + val totalNumberUsers = 11 + val source: MutableList = ArrayList(totalNumberUsers) + + for (i in 1..totalNumberUsers) { + val user = User().apply { + lastname = user.lastname + username = "${user.lastname}-${String.format("%03d", i)}" + } + + source.add(user) + } + + repository.saveAll(source) + + val users = repository.findByLastnameOrderByUsernameAsc(user.lastname, PageRequest.of(1, 5)) + + assertThat(users).containsAll(source.subList(5, 10)) + } + + @Test + fun findFirst2ByOrderByLastnameAsc() { + val user0 = User().apply { lastname = "lastname-0" } + + val user1 = User().apply { lastname = "lastname-1" } + + val user2 = User().apply { lastname = "lastname-2" } + + // we deliberately save the items in reverse + repository.saveAll(listOf(user2, user1, user0)) + + val result = repository.findFirst2ByOrderByLastnameAsc() + + assertThat(result).containsExactly(user0, user1) + } + + @Test + fun findTop2ByWithSort() { + val user0 = User().apply { lastname = "lastname-0" } + + val user1 = User().apply { lastname = "lastname-1" } + + val user2 = User().apply { lastname = "lastname-2" } + + // we deliberately save the items in reverse + repository.saveAll(listOf(user2, user1, user0)) + + val resultAsc = repository.findTop2By(Sort.by(Sort.Direction.ASC, "lastname")) + + assertThat(resultAsc).containsExactly(user0, user1) + + val resultDesc = repository.findTop2By(Sort.by(Sort.Direction.DESC, "lastname")) + + assertThat(resultDesc).containsExactly(user2, user1) + } + + @Test + fun findByFirstnameOrLastnameUsingSpEL() { + val first = User().apply { lastname = "lastname" } + + val second = User().apply { firstname = "firstname" } + + val third = User() + + repository.saveAll(listOf(first, second, third)) + + val reference = User().apply { firstname = "firstname"; lastname = "lastname" } + + val users = repository.findByFirstnameOrLastname(reference) + + assertThat(users).contains(first) + assertThat(users).contains(second) + assertThat(users).hasSize(2) + } + + /** + * Streaming data from the store by using a repository method that returns a [Stream]. Note, that since the + * resulting [Stream] contains state it needs to be closed explicitly after use! + */ + @Test + fun useJava8StreamsWithCustomQuery() { + val user1 = repository.save(User().apply { firstname = "Customer1"; lastname = "Foo" }) + val user2 = repository.save(User().apply { firstname = "Customer2"; lastname = "Bar" }) + + repository.streamAllCustomers().use { stream -> + assertThat(stream.collect(Collectors.toList())).contains(user1, user2) + } + } + + /** + * Streaming data from the store by using a repository method that returns a [Stream] with a derived query. + * Note, that since the resulting [Stream] contains state it needs to be closed explicitly after use! + */ + @Test + fun useJava8StreamsWithDerivedQuery() { + val user1 = repository.save(User().apply { firstname = "Customer1"; lastname = "Foo" }) + val user2 = repository.save(User().apply { firstname = "Customer2"; lastname = "Bar" }) + + repository.findAllByLastnameIsNotNull().use { stream -> + assertThat(stream.collect(Collectors.toList())).contains(user1, user2) + } + } + + /** + * Query methods using streaming need to be used inside a surrounding transaction to keep the connection open while + * the stream is consumed. We simulate that not being the case by actively disabling the transaction here. + */ + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + fun rejectsStreamExecutionIfNoSurroundingTransactionActive() { + Assertions.assertThrows(InvalidDataAccessApiUsageException::class.java) { + repository.findAllByLastnameIsNotNull() + } + } + + /** + * Here we demonstrate the usage of [CompletableFuture] as a result wrapper for asynchronous repository query + * methods. Note, that we need to disable the surrounding transaction to be able to asynchronously read the written + * data from another thread within the same test method. + */ + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + fun supportsCompletableFuturesAsReturnTypeWrapper() { + repository.save(User().apply { firstname = "Customer1"; lastname = "Foo" }) + repository.save(User().apply { firstname = "Customer2"; lastname = "Bar" }) + + runBlocking { + val users = repository.readAllBy().await() + assertThat(users).hasSize(2) + } + + repository.deleteAll() + } +} \ No newline at end of file diff --git a/jdbc/spring-data-jpa/src/test/resources/application-test.properties b/jdbc/spring-data-jpa/src/test/resources/application-test.properties new file mode 100644 index 0000000..4abc7c5 --- /dev/null +++ b/jdbc/spring-data-jpa/src/test/resources/application-test.properties @@ -0,0 +1,5 @@ +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=false +spring.jpa.properties.hibernate.highlight_sql=true + +spring.jpa.properties.hibernate.hbm2ddl.auto=create \ No newline at end of file diff --git a/jdbc/spring-data-jpa/src/test/resources/logback.xml b/jdbc/spring-data-jpa/src/test/resources/logback.xml new file mode 100644 index 0000000..5a56f30 --- /dev/null +++ b/jdbc/spring-data-jpa/src/test/resources/logback.xml @@ -0,0 +1,19 @@ + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 1dfcae2..1f1188a 100644 --- a/pom.xml +++ b/pom.xml @@ -15,10 +15,10 @@ UTF-8 - 2.20.0 + 2.22.1 1.82 - 2.1.7 + 2.1.11 @@ -66,7 +66,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.11.0 + 3.12.1 1.8 1.8 @@ -81,7 +81,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.1.0 + 3.2.5 true @@ -91,7 +91,7 @@ org.apache.maven.plugins maven-dependency-plugin - 3.5.0 + 3.6.1 copy-dependencies diff --git a/ydb-cookbook/src/main/java/tech/ydb/examples/simple/AlterTable.java b/ydb-cookbook/src/main/java/tech/ydb/examples/simple/AlterTable.java index 6242f43..3b9dada 100644 --- a/ydb-cookbook/src/main/java/tech/ydb/examples/simple/AlterTable.java +++ b/ydb-cookbook/src/main/java/tech/ydb/examples/simple/AlterTable.java @@ -1,6 +1,7 @@ package tech.ydb.examples.simple; import java.time.Duration; + import tech.ydb.core.grpc.GrpcTransport; import tech.ydb.examples.SimpleExample; import tech.ydb.table.Session; @@ -8,7 +9,6 @@ import tech.ydb.table.description.TableColumn; import tech.ydb.table.description.TableDescription; import tech.ydb.table.settings.AlterTableSettings; -import tech.ydb.table.values.OptionalType; import tech.ydb.table.values.PrimitiveType; @@ -42,8 +42,8 @@ protected void run(GrpcTransport transport, String pathPrefix) { session.alterTable(tablePath, new AlterTableSettings() .setTraceId("some-trace-id") - .addColumn("name", OptionalType.of(PrimitiveType.Text)) - .addColumn("age", OptionalType.of(PrimitiveType.Uint32)) + .addNullableColumn("name", PrimitiveType.Text) + .addNullableColumn("age", PrimitiveType.Uint32) .dropColumn("value") ).join().expectSuccess("cannot alter table");