При работе со 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"]
представляет собой более лаконичное решение описанной проблемы.