apache / apache/grails-core

Domain class mapping with `fetch: 'join'` on OneToOne association results in n+1 queries

Open
#14,343 2 comments 2 reactions 0 assignees View on GitHub
relates-to: grails-data-hibernate5
Dominant language
Groovy
Stars
2.9k
Forks
975
Avg merge
1d 22h
Merged PRs (30d)
92

Description

We have been troubleshooting n+1 query behavior resulting from a simple association between two domain objects. We believe this should be prevented by specifying a `fetch: 'join'` mapping on the parent object, but our tests show this mapping having no effect.

We are left wondering if we have some ongoing misunderstanding about the [documented features in GORM](https://gorm.grails.org/latest/hibernate/manual/index.html#_configuring_eager_fetching) to control fetching of associated objects, or if something has regressed in GORM itself and this essential feature is broken.

We have created a sample application to reproduce: https://github.com/xh/gorm-list-nplus1-bug/tree/nPlusOneWithSingleAssociation - please see the `nPlusOneWithSingleAssociation` branch for the clearest expression of the issue that we could create.

Grails 6.0.0
GORM 8.0.0. (via Grails)
Hibernate 5.6.15.Final (via GORM)
Database: H2 (via Grails, although same behavior replicates on MySQL, MSSQL)

Parent class:
```groovy
class Flight {

String number
Location destination

static mapping = {
destination fetch: 'join'
}
}
```

Child class:
```groovy
class Location {
String city
}
```

Within `TestController` we have a `findAll` endpoint that effectively does the following:

```groovy
flights = Flight.findAll()
flights.collect { f ->
[number: f.number, city: f.destination.city]
}
```

**Expected result:** SQL logging shows a single query to load all rows from `flight`, with a join to `location` to include location-based fields for each flight.

**Actual result:** SQL logging shows initial query to fetch all flights without any join, and then n queries to fetch each flight's location, one-by-one. Here are the logs from that call - we bootstrap three flights in total, each with a single location:

```
''2023-08-31 13:03:18.627 INFO --- [nio-8080-exec-1] io.xh.grails.TestController : --------------------------------------------------
''2023-08-31 13:03:18.627 INFO --- [nio-8080-exec-1] io.xh.grails.TestController : START Find all flights >>
''2023-08-31 13:03:18.627 DEBUG --- [nio-8080-exec-1] o.g.o.hibernate.GrailsHibernateTemplate : Found thread-bound Session for HibernateTemplate
''2023-08-31 13:03:18.671 DEBUG --- [nio-8080-exec-1] org.hibernate.SQL : select flight0_.id as id1_0_, flight0_.version as version2_0_, flight0_.number as number3_0_, flight0_.destination_id as destinat4_0_ from flight flight0_
''2023-08-31 13:03:18.678 DEBUG --- [nio-8080-exec-1] o.g.o.hibernate.GrailsHibernateTemplate : Not closing pre-bound Hibernate Session after HibernateTemplate
''2023-08-31 13:03:18.678 INFO --- [nio-8080-exec-1] io.xh.grails.TestController : << END Find all flights
''2023-08-31 13:03:18.678 INFO --- [nio-8080-exec-1] io.xh.grails.TestController : --------------------------------------------------
''2023-08-31 13:03:18.679 INFO --- [nio-8080-exec-1] io.xh.grails.TestController :
''2023-08-31 13:03:18.679 INFO --- [nio-8080-exec-1] io.xh.grails.TestController : --------------------------------------------------
''2023-08-31 13:03:18.679 INFO --- [nio-8080-exec-1] io.xh.grails.TestController : START Render with destination.city >>
''2023-08-31 13:03:18.681 DEBUG --- [nio-8080-exec-1] org.hibernate.SQL : select location0_.id as id1_1_0_, location0_.version as version2_1_0_, location0_.city as city3_1_0_ from location location0_ where location0_.id=?
''2023-08-31 13:03:18.682 DEBUG --- [nio-8080-exec-1] org.hibernate.SQL : select location0_.id as id1_1_0_, location0_.version as version2_1_0_, location0_.city as city3_1_0_ from location location0_ where location0_.id=?
''2023-08-31 13:03:18.683 DEBUG --- [nio-8080-exec-1] org.hibernate.SQL : select location0_.id as id1_1_0_, location0_.version as version2_1_0_, location0_.city as city3_1_0_ from location location0_ where location0_.id=?
''2023-08-31 13:03:18.691 INFO --- [nio-8080-exec-1] io.xh.grails.TestController : << END Render with destination.city
''2023-08-31 13:03:18.691 INFO --- [nio-8080-exec-1] io.xh.grails.TestController : --------------------------------------------------
```

That same controller has another `findAllWithFetchArg()` example that attempts to use an explicit `fetch` argument to `findAll`, which also does not work as expected/documented:

```groovy
flights = Flight.findAll(fetch: [destination: 'join'])
flights.collect { f ->
[number: f.number, city: f.destination.city]
}
```

**Expected result:** SQL logging shows a single query to load all rows from `flight`, with a join to `location` to include location-based fields for each flight.

**Actual result:** SQL logging shows initial query to fetch all flights with an odd and seemingly useless `inner join location`, but no fields read from location, and then the same n queries to fetch each flight's location, one-by-one:

```
''2023-08-31 13:04:50.116 INFO --- [nio-8080-exec-3] io.xh.grails.TestController : --------------------------------------------------
''2023-08-31 13:04:50.116 INFO --- [nio-8080-exec-3] io.xh.grails.TestController : START Find all flights with fetch/join query arg >>
''2023-08-31 13:04:50.117 DEBUG --- [nio-8080-exec-3] o.g.o.hibernate.GrailsHibernateTemplate : Found thread-bound Session for HibernateTemplate
''2023-08-31 13:04:50.135 DEBUG --- [nio-8080-exec-3] org.hibernate.SQL : select flight0_.id as id1_0_, flight0_.version as version2_0_, flight0_.number as number3_0_, flight0_.destination_id as destinat4_0_ from flight flight0_ inner join location location1_ on flight0_.destination_id=location1_.id
''2023-08-31 13:04:50.137 DEBUG --- [nio-8080-exec-3] o.g.o.hibernate.GrailsHibernateTemplate : Not closing pre-bound Hibernate Session after HibernateTemplate
''2023-08-31 13:04:50.137 INFO --- [nio-8080-exec-3] io.xh.grails.TestController : << END Find all flights with fetch/join query arg
''2023-08-31 13:04:50.137 INFO --- [nio-8080-exec-3] io.xh.grails.TestController : --------------------------------------------------
''2023-08-31 13:04:50.137 INFO --- [nio-8080-exec-3] io.xh.grails.TestController :
''2023-08-31 13:04:50.137 INFO --- [nio-8080-exec-3] io.xh.grails.TestController : --------------------------------------------------
''2023-08-31 13:04:50.137 INFO --- [nio-8080-exec-3] io.xh.grails.TestController : START Render with destination.city >>
''2023-08-31 13:04:50.137 DEBUG --- [nio-8080-exec-3] org.hibernate.SQL : select location0_.id as id1_1_0_, location0_.version as version2_1_0_, location0_.city as city3_1_0_ from location location0_ where location0_.id=?
''2023-08-31 13:04:50.137 DEBUG --- [nio-8080-exec-3] org.hibernate.SQL : select location0_.id as id1_1_0_, location0_.version as version2_1_0_, location0_.city as city3_1_0_ from location location0_ where location0_.id=?
''2023-08-31 13:04:50.138 DEBUG --- [nio-8080-exec-3] org.hibernate.SQL : select location0_.id as id1_1_0_, location0_.version as version2_1_0_, location0_.city as city3_1_0_ from location location0_ where location0_.id=?
''2023-08-31 13:04:50.139 INFO --- [nio-8080-exec-3] io.xh.grails.TestController : << END Render with destination.city
''2023-08-31 13:04:50.139 INFO --- [nio-8080-exec-3] io.xh.grails.TestController : --------------------------------------------------
```

I know there are several gotchas and non-intuitive behaviors around GORM, Hibernate, associations, and n+1 - but this really seems like a bad break from the expected behavior for such a core and straightforward use case.

If anyone can confirm or advise we would appreciate it greatly!

Contributor guide

Open the contributing guide

Research direction

Start with the nPlusOneWithSingleAssociation branch of the linked sample application and inspect TestController, the Flight mapping, and the Location class. Run both findAll examples with SQL logging enabled, then compare the generated queries with the expected single joined query; done means the association does not issue one location query per flight.

Written by the indexing model from the issue text.

Assessment

Tech stack
groovy
Domain
backend, databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.