I am trying to batch fetch a collection of nested entities from my database. The resulting dataset ranges in the thousands of entities so my approach is to fetch entities in a paged fashion based on this post. The data is fetched from a web-based frontend and the pure un-paged query takes up to 10 seconds (unacceptable).
The "parent" entities are fetched correctly, however the "child" entities are seemingly not fetched. In the resulting list of entities from TestRepository.getRankedTests(...), the list of "child" entities is not initialised and accessing them will result in a LazyInitializationException
. This points in the direction of an issue with my SqlResultMapping
, but I am not able to see the mistake. I've tried to inject errors into my SqlResultMapping
for the child, and it causes hibernate to complain at runtime, so it seems to try to map my config to the properties of the child entity, though the uninitialised collection of child entities dumbfound me.
@NamedNativeQuery(
name = "Test.getRankedTests",
query = """
select *
from (
select
*,
DENSE_RANK() over (
order by "o.id"
) rank
from (
select
o.id as "o.id",
o.version as "o.version",
a.id as "a.id",
a.organisation_id as "a.organisation_id",
a.type as "a.type"
from organisation o
left join address a on o.id = a.organisation_id
order by o.organisation_number
) o_a_an_c
) o_a_an_c_r
where o_a_an_c_r.rank > :min and o_a_an_c_r.rank <= :max
""",
resultSetMapping = "TestMapping"
)
@SqlResultSetMapping(
name = "TestMapping",
entities = [
EntityResult(
entityClass = Test::class,
fields = [
FieldResult(name = "id", column = "o.id"),
FieldResult(name = "version", column = "o.version"),
]
),
EntityResult(
entityClass = TestChild::class,
fields = [
FieldResult(name = "id", column = "a.id"),
FieldResult(name = "organisation", column = "a.organisation_id"),
]
),
]
)
@Entity
@Table(name = "organisation")
class Test(
@Id
val id: Long,
val version: Long,
@OneToMany(mappedBy = "organisation", cascade = [CascadeType.ALL], orphanRemoval = true)
val addresses: List<TestChild>,
)
@Entity
@Table(name = "address")
@Suppress("LongParameterList")
class TestChild(
@Id
val id: Long,
@ManyToOne(fetch = FetchType.LAZY)
val organisation: Test,
)
@Repository
interface TestRepository : JpaRepository<Test, Long> {
fun getRankedTests(
min: Long,
max: Long
): List<Test>
}
Cudos to Christian Beikov for a good proposal. The missing link here was the ResultTransformer. Since the native query will end up with both the parent and child on the same JDBC row, we will end up with an object array containing both. The ResultTransformer will have the responsibility of mapping that object array back to an entity hierarchy. Here's how I fixed it:
Added a DAO for fetching the results with an entityManager:
@Repository
class Dao(
@PersistenceContext
private val entityManager: EntityManager
) {
fun getRankedTests(): List<Test> =
entityManager.createNamedQuery("Test.getRankedTests")
.setParameter("max", 5)
.setHint(QueryHints.HINT_READONLY, true)
.unwrap(NativeQuery::class.java)
.setResultTransformer(TestResultTransformer(entityManager))
.resultList.filterIsInstance(Test::class.java)
}
Created the following ResultTransformer:
class TestResultTransformer(private val entityManager: EntityManager) : BasicTransformerAdapter() {
override fun transformList(
list: List<*>
): List<Test> {
val identifiableMap: MutableMap<Long, Test> = mutableMapOf()
for (entityArray in list) {
if (entityArray is Array<*>) {
var test: Test? = null
var testChild: TestChild? = null
for (tuple in entityArray) {
entityManager.detach(tuple);
when (tuple) {
is Test -> test = tuple
is TestChild -> testChild = tuple
else -> {
throw UnsupportedOperationException(
"Tuple " + tuple?.javaClass + " is not supported!"
)
}
}
}
if (test != null) {
val key = test.id
if (!identifiableMap.containsKey(key)) {
identifiableMap[key] = test
test.addresses = mutableListOf()
}
if (testChild != null) {
test.addresses.add(testChild)
}
}
}
}
return identifiableMap.values.toList()
}
}