null

Подводные камни JPQL-запросов с fetch-полями в Spring Data JPA

При работе со Spring Data JPA иногда возникают ситуации, когда JPQL-запрос, который должен извлекать сущность по её ID, возвращает null. Это происходит, даже если сущность с заданным ID точно существует в базе данных.

Как же так получается? Рассмотрим данную проблему на примере Authors и Books.
 

Пример запроса и сущностей

JPQL-запрос

@Query("select a from Author a join fetch a.books where a.id = :id")
fun getByIdFetchBooks(id: Long): Author?

Сущность Author

@Table(name = "authors")
@Entity
open class Author(
    open val name: String,
    @JoinTable(
        name = "author_books",
        joinColumns = [JoinColumn(name = "author_id")],
        inverseJoinColumns = [JoinColumn(name = "book_id")]
    )
    @ManyToMany
    open val books: Set<Book> = emptySet()
)

Сущность Book

@Table(name = "books")
@Entity
open class Book(
    open val title: String,
    open val isbn: String
)

Описание проблемы

В JPQL-запросе используется join fetch для того, чтобы загрузить сущность Author вместе с коллекцией его книг – связанными сущностями Book. Запрос должен возвращать Author по заданному id. Однако, если у автора нет связанных книг, запрос вернёт null, несмотря на то, что автор с таким id существует в базе данных.

Причина

Проблема кроется в нюансах работы join: в JPQL-запросе по умолчанию используется INNER JOIN , возвращающий только те строки, которые имеют соответствующие записи в обеих таблицах (пересечение двух множеств). Если у автора нет никаких книг (т.е. связанных записей в таблице books), то INNER JOIN не найдёт пересечения с этим множеством, и запрос вернёт пустой результат.

Решение

Для решения данной проблемы можно использовать LEFT JOIN FETCH вместо INNER JOIN FETCH.

LEFT JOIN FETCH вернёт все записи из левой таблицы (authors), даже если в правой таблице (books) нет соответствующих записей.

Обновленный JPQL-запрос

@Query("select a from Author a left join fetch a.books where a.id = :id")
fun getByIdFetchBooksCorrectly(id: Long): Author?

Тест

Создадим JUnit-тест для воспроизведения нашей проблемы и её решения.

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.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class AuthorRepositoryTest {

    @Autowired
    private lateinit var authorRepository: AuthorRepository

    @BeforeEach
    fun setUp() {
        // Чистим репозиторий перед каждым тестом
        authorRepository.deleteAll()

        // Создаем и сохраняем автора без книг
        val author = Author(
            name = "John Doe",
            books = emptySet()
        )
        authorRepository.save(author)
    }


@Test
    fun `test findByIdFetchBooks with no books incorrectly`() {
        // Находим автора по ID
        val savedAuthor = authorRepository.findAll().firstOrNull()
        assertNotNull(savedAuthor)

        val author = authorRepository.findByIdFetchBooks(savedAuthor!!.id)

        // Проверяем, что автор не найден
        assertNull(author)
    }

    @Test
    fun `test findByIdFetchBooks with no books correctly`() {
        // Находим автора по ID
        val savedAuthor = authorRepository.findAll().firstOrNull()
        assertNotNull(savedAuthor)

        val author = authorRepository.findByIdFetchBooksCorrectly(savedAuthor!!.id)

        // Проверяем, что автор найден и его набор книг пуст
        assertNotNull(author)
        assertTrue(author!!.books.isEmpty())
    }
}

Более изящное решение

Также для решения этой проблемы можно воспользоваться аннотацией @EntityGraph, которая позволяет перечислить связанные сущности, которые должны быть загружены вместе с основной сущностью.

В данном примере, attributePaths = ["books"] указывает, что вместе с сущностью Author должны быть загружены связанные сущности Book.

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

interface AuthorRepository : JpaRepository<Author, Long> {

    @EntityGraph(attributePaths = ["books"])
    @Query("select a from Author a where a.id = :id")
    fun findByIdWithBooks(@Param("id") id: Long): Author?
}

 

Заключение

Использование LEFT JOIN FETCH вместо INNER JOIN FETCH в JPQL-запросах позволяет избежать ситуации, когда запрос возвращает null для сущностей, у которых нет связанных записей в другой таблице. Это особенно полезно в случаях, когда нужно загружать сущности вместе с их связанными коллекциями, которые могут оказаться пустыми.

Аннотация @EntityGraph с параметром  attributePaths = ["books"] представляет собой более лаконичное решение описанной проблемы.