Repository navigation
Conversation
Sorting a where query by a property of an association, such as sort: 'club.name', failed with a QueryException when the query also had criteria on that association. The criteria join the association under a generated alias, and the order was added with the association path, which Hibernate cannot resolve. The order now uses the alias of the join.
jdaugherty
left a comment
There was a problem hiding this comment.
Verified locally on 0676c67 with :grails-data-hibernate5-core:test. The spec passes, and probes beyond it pass as well: a paged list(max: 2) with totalCount, max plus offset descending, .order('club.name', 'desc') on the detached criteria, a disjunction that uses the association (club.name == 'Arsenal' || name == '...'), a multi-key sort map mixing club.name and name, an explicit alias in the where query combined with a sort by club.name, and ignoreCase: false. The paged count works because HibernateQuery.clone() copies the criterion entries and the subcriteria but not the orders, so the count query carries no order by.
The change is right: getOrCreateAlias registers the join under both the association path and the alias, so resolving the order through criteriaAndAlias.alias covers the generated alias, the explicit alias and the association id in one place, and the detachedCriteria branch closes a latent NPE the old code had for a subquery-backed query. I also confirmed that a sort by an alias the query does not define is silently ignored (the else branch finds no such property), as the docs now say.
One thing to point out: the sort resolves a single association level only. A nested path such as team.club.name still throws, whether or not the where query joins it, while the new docs sentence reads as if any association path works. Details inline on order(). The other inline note is a merge-up heads-up for the Hibernate 7 docs on 8.0.x.
| Without an explicit alias, sorting on association properties may fail due to dynamically generated aliases. | ||
|
|
||
| For example the following query will fail: | ||
| To sort by a property of an associated entity, use the association path. This also works when the `where` query uses the same association: |
There was a problem hiding this comment.
Merge-up note: on 8.0.x the Hibernate 7 copy of this guide (grails-data-hibernate7/docs/src/docs/asciidoc/querying/whereQueries.adoc, section "Query Aliases and Sorting") still shows the owner.lastName sort as failing because of a dynamic alias, while the Hibernate 7 WhereQueryWithAssociationSortSpec there already expects it to work. The merge-up carries only this file, so that section stays stale unless it is aligned in the merge-up or a follow-up.
There was a problem hiding this comment.
Thanks. That section was never updated for Hibernate 7. I'll align it in a separate PR against 8.0.x, after checking the Hibernate 7 behavior a bit beyond what its spec covers.
| if (createdAssociationPaths.containsKey(sortHead)) { | ||
| // sortHead may be either an explicit alias or an association path that | ||
| // was joined (with a generated alias) by a where query criterion. | ||
| // Hibernate can only resolve the property through the alias. |
There was a problem hiding this comment.
This resolves one level only: property is split at the first dot, so a sort by a nested path such as team.club.name (Player -> Team -> Club) still throws a QueryException, whether or not the where query joins team.club. It threw before this change too, so it is not a regression, but the new docs sentence ("To sort by a property of an associated entity, use the association path") reads as if any depth works. Either extend this to nested paths, or state the one-level scope in the docs and leave nested paths to a follow-up.
There was a problem hiding this comment.
Right, it's one level only, as before. I've stated that in the docs now and would leave nested paths for a follow-up. Supporting them means walking the path, reusing the joins that the criteria register under the full path (such as team.club), and adding inner joins for the rest, which I'd rather not do on 7.0.x.
Explicit aliases don't help with nested paths either: the where DSL doesn't recognize def c = team.club, or def t = team; def c = t.club, as an alias, so a query that uses c doesn't compile.
There was a problem hiding this comment.
I'm actually going to fix this ...
There was a problem hiding this comment.
Pushed c0c5a80 to this branch. It reverts b494caa and makes order() walk the sort path one association at a time: a join the criteria registered under the full path, such as team.club, is reused, and each association not joined yet gets an inner join, so team.club.name sorts whether or not the where query joins it, and t.club.name works through an explicit alias of team. A sort by two properties of the same association no longer raises duplicate association path either, since the sort join is registered instead of created for every order. WhereQueryWithAssociationSortSpec covers the nested and duplicate cases (all six failed before the change), and the docs sentence now says the path can cross more than one association.
There was a problem hiding this comment.
@matrei I think it's worth doing this after looking at it. Take a look at the change and see what you think.
There was a problem hiding this comment.
Thanks, that looks good to me, and it brings Hibernate 5 in line with Hibernate 7, which already sorts by nested paths. I ran the grails-data-hibernate5-core tests on c0c5a80 locally, since CI didn't run them after I removed run ci, and they all pass.
Comparing a few queries before and after the change, the results are the same except for one: with fetch: [owner: JoinType.LEFT] on a nullable association, a sort by owner.name now keeps the rows without an owner, because the sort join uses the requested join type instead of an inner join. I think that's right, but since it changes results on 7.0.x, I'll mention it in the PR description.
A sort by a path through more than one association, such as owner.employer.name, is still not supported.
This reverts commit b494caa.
A sort by a path that crosses more than one association, such as team.club.name, failed with a QueryException whether or not the where query joined the nested association, because order() split the path at its first dot only. The sort property is now qualified with the alias of the last association on its path: an association the query has already joined is reused, and one it has not joined yet is joined for the sort and registered like any other join. A sort by two properties of the same association no longer fails with "duplicate association path" either, since the sort join is registered instead of created anew for every order.
matrei
left a comment
There was a problem hiding this comment.
I reviewed c0c5a80 and it looks good to me. Walking the path one association at a time and going through getOrCreateAlias reuses the joins the where query made. It also registers the sort's own join, which fixes the duplicate association path error for two sorts on the same association.
Two minor points, neither blocking:
- No test covers the
LEFTfetch join now being used for the sort. - The docs could say that a sort joins an association with an inner join unless
fetchasks for another type, so a sort byowner.nameleaves out pets without an owner. That was already the case before this change.
jamesfredley
left a comment
There was a problem hiding this comment.
Sorting by an association path reuses a join the where query already made and orders by that join's alias. A path through more than one association works the same way. Not approving. A sort by a nested identifier, such as team.club.id, inner-joins the last association and drops rows whose association is null. Those rows were returned before, because the identifier was ordered through the foreign key.
Since order() walks the whole sort path, a sort by the identifier of an association that follows another one on the path, such as team.club.id, joined that last association too. The join is an inner join unless fetch requests another type, so the sort left out the rows where the association is null. Before, only the first association was joined and Hibernate read club.id from the foreign key of team, which kept them. The identifier is sorted by the foreign key again when the entity before the association holds it. An association without such a foreign key, a hasOne or the inverse side of a one-to-one, is still joined, and so is the first association of a path, as it always has been. The spec also covers the join type of a sort join: an inner join by default, and a left join when fetch requests one. Team.club is nullable in the fixture for these cases. The docs describe the inner join, how to request a left join, and the identifier of a nested association.
jdaugherty
left a comment
There was a problem hiding this comment.
Pushed 5e71443 for the feedback on c0c5a80.
@jamesfredley: a sort by a nested identifier keeps its rows again. team.club.id is sorted by the foreign key on team, as it was on 7.0.x, and club is joined only when there is no such foreign key. Details are in the thread.
@matrei: both points are in the same commit.
WhereQueryWithAssociationSortSpecnow covers the join type of a sort join: an inner join by default, which leaves out a team without a club, and a left join withfetch: [club: JoinType.LEFT], which keeps it.Team.clubis nullable in the fixture for that.- The docs say that an association is joined with an inner join unless the query requests another type, show the
fetchexample, and mention the nested identifier.
An association that a where query joined only for a sort was joined with an inner join, so the sort left out the rows where the association is null: a sort by club.name or club.id dropped the teams without a club, from the list and from the total count of a paged list. A sort must not change which rows a query returns, so the association is now joined with a left join, unless the query requests another join type for it. That keeps the rows for every association on a sort path, so the identifier of a nested association no longer needs to be sorted by its foreign key, and that special case is removed. An association that the where query has criteria on is still joined as the criteria join it, which is an inner join by default.
list, find and findAll with an example, findWhere, findAllWhere and a criteria query that is passed pagination arguments joined an association for a sort with an inner join, as where queries did. A sort by club.name left out the teams without a club, findAllWhere did not find such a team at all, and the total count of a paged list did not count it. These sorts now join the association with a left join too, so a sort no longer changes which instances a query returns. Dynamic finders sort through the same code as where queries and already keep those instances. The new spec covers them as well. The upgrade notes describe the change and how to leave the instances out as before.
jdaugherty
left a comment
There was a problem hiding this comment.
Follow-up to my review above: 2154153 and 1b70845 replace the approach of 5e71443.
A sort should not change which rows a query returns, so an association that is joined only for a sort is now joined with a left join. That keeps the rows for every association on a sort path, so the foreign-key ordering for a nested identifier from 5e71443 is removed again.
@matrei: this goes beyond the two points in your review and changes results on 7.0.x, so I have updated the PR description and added section 22 to the upgrade notes.
- Where queries, detached criteria and dynamic finders (2154153): without a requested join type the sort join was an inner join, and a sort by
club.nameorclub.idleft out the teams without a club, from the list and from thetotalCountof a paged list. They are kept now. A join type requested for the association, such asfetch: [club: JoinType.INNER], is used for the sort, and an association the query has criteria on is still joined by the criteria.calculateProjectionPropertyNamein the same class already left-joins the associations it joins for a projection, for the same reason. list,findandfindAllwith an example,findWhere,findAllWhereand a criteria query with pagination arguments (1b70845) had the same inner join inGrailsHibernateQueryUtilsandGrailsHibernateUtil.Team.list(sort: 'club.name')left out the teams without a club, andfindAllWherewith that sort did not find such a team at all. They use a left join now too.- The where query docs say that sorting does not change which rows a query returns.
WhereQueryWithAssociationSortSpecand the newAssociationSortSpeccover it, and nine cases of each fail on c0c5a80.
@jamesfredley: the details for team.club.id are in the thread.
A criteria query that is passed pagination arguments and a sort by a path through more than one association, such as team.club.name, failed when the total count was read: the sort joined each association from the criteria of the one before it, and the count recreates every join from the root criteria by its path, so it looked for club on the root entity. When the criteria already used the first association of the path, the query failed with "duplicate association path" instead. The sort now reuses a join that the criteria have for an association path, and creates any other from the root criteria with its whole path.
|
@jamesfredley @matrei @borinquenkid, a question that came up while testing this PR and that the PR does not change: what should the With four teams, one of which has no club (
"Fetched" is So in
It also means that The options I see:
What do you think should happen, and on which branch? The two rows where Hibernate 7 does not fetch look like a separate gap there. |
list with a map of sort properties sorted by the last entry only: each entry was passed to CriteriaQuery.orderBy, which replaces the orders of the query. Each order is now added to the ones the query has.
jamesfredley
left a comment
There was a problem hiding this comment.
A sort by a nested identifier such as team.club.id now left-joins that association and keeps the rows whose association is null. A paged criteria count can sort by a nested path, and each entry of a sort map is kept. Not approving. A sort stored on the query is applied before the list arguments, so an inner join requested there does not replace the left join the sort already created.
A sort joined an association as soon as the sort was added to the
query. A sort stored on a where query is added before the arguments of
list are read, so the join was already a left join when fetch requested
another type:
Team.where { ... }.sort('club.name').list(fetch: [club: JoinType.INNER])
returned the teams without a club. Criteria passed to list are added
after a stored sort too, and reused its left join instead of the inner
join they get without the sort.
A sort now only reserves the alias of an association that nothing else
in the query has joined, and the join is made when the query is
executed, with the join type the query requests for the association by
then, or else a left join. Criteria or a projection that need the
association before that join it themselves, under the reserved alias.
A where query, a detached criteria or a dynamic finder can request a
join type for an association, with fetch: [club: JoinType.INNER] or
join('club', JoinType.INNER). The type was only used when criteria or a
sort joined the association. Otherwise the association was fetched with
the outer join of its fetch mode, so the query returned the instances
without the association, and adding a sort to it changed the rows it
returned.
A requested join type other than a left join is now applied when the
query is executed, by joining every association on the requested path
that the query has not joined. Hibernate fetches a collection only
through an outer join, so an inner join requested for a collection
keeps the outer fetch join and restricts the query to the rows whose
collection is not empty.
The fetching guide describes the join types, and section 23 of the
upgrade notes the change in results.
The criteria builder accepted a join type, in join(String, JoinType) and as a JoinType in the fetch argument of list, and did not use it. join set a fetch mode, which Hibernate joins with an outer join unless the association is required, and a JoinType in the fetch argument matched no fetch mode, so the association was not fetched at all. Both now join and fetch the association with the requested join type, as a where query does. The join types requested with join are applied once the criteria are built, so that an association that the criteria have a block for keeps the join of that block. An inner join requested for a collection keeps the outer fetch join and restricts the criteria to the rows whose collection is not empty. The fetching guide and section 23 of the upgrade notes now cover the criteria query.
updateAll and deleteAll apply a requested join type with a subquery. For a path that leads back to the queried class, such as the parent of a category, the subquery selects from the table that the update or the delete modifies. MySQL, and MariaDB before version 10.3, do not allow that, so the statement failed there with an error of the database. On these databases, updateAll and deleteAll now throw an UnsupportedOperationException that names the association and says to restrict the query with criteria instead. Other databases run the statement.
jdaugherty
left a comment
There was a problem hiding this comment.
One more commit since the review above. The head is 1ab83ab.
updateAll and deleteAll apply a requested join type with a subquery (cc58a3b). For a path that leads back to the queried class, such as the parent of a category, that subquery selects from the table that the update or the delete modifies. MySQL, and MariaDB before version 10.3, do not allow that, so the statement failed there with an error of the database. With 1ab83ab, updateAll and deleteAll throw an UnsupportedOperationException on these databases. It names the association and says to restrict the query with criteria instead, as in Category.where { parent != null }. Other databases run the statement.
RequestedJoinBulkOperationDialectSpec covers this with the MySQL and MariaDB dialects, and four of its nine cases fail on cc58a3b. The fetching guide and section 23 of the upgrade notes describe it.
jamesfredley
left a comment
There was a problem hiding this comment.
Not approved. A listener that adds an association restriction to findAll is ignored, and the query fails to resolve the alias. Sorting an example query by an association still returns a row whose association is null when an inner join was requested. A criteria list that asks to fetch a collection it sorts by now fetches it.
A review of the branch, with each finding run against 7.0.x, found
these regressions:
- deleteAll with an inner join requested for a collection that has a
join table deleted no instance, and removed the join rows of the
instances that it should have deleted. Hibernate deletes the join
rows first, with the same restriction, which then no longer finds
the instances. The identifiers are now selected first, and the
instances are deleted by identifier.
- updateAll and deleteAll matched the side of a one-to-one that the
other class has the foreign key of by comparing the two primary keys,
so they changed other instances than list returns. The subquery is
now matched through the property that refers back, as Hibernate maps
the association.
- The condition that keeps a sort join of a collection from fetching
the collection was part of the join condition. Hibernate combines it
with the filter of a collection that has a join table, such as the
tenant filter of discriminator based multi-tenancy, into SQL that no
database accepts. It is now added to the where clause, through the
criteria of the join.
- Hibernate gives a join of a collection that it does not fetch the
column aliases of the next collection that it fetches. A sort by a
collection, in a query that fetched another collection of the same
class, filled the fetched collection with the elements of the sorted
one. The sort now fetches its collection as well when the query
fetches another one. When both are bags, which Hibernate does not
fetch in one query, it joins its collection with an inner join, as
7.0.x did.
- A fetch mode requested after a join type, as join('club') or
fetch: [club: 'join'] after join('club', JoinType.LEFT), dropped the
join type also for the join that criteria on the association make.
That join keeps it again, as on 7.0.x. A detached criteria keeps a
join type that a later fetch mode replaced, so that it reaches the
query.
- A paged criteria query sorted through an alias, as sort: 't.club.name'
with createAlias('team', 't') and createAlias('t.club', 'c'), lost
its sort, because the alias of a join is now recorded under the path
of the association.
- An association after the first one on a sort path or on a requested
join path was joined from the root criteria by the whole path, which
Hibernate reads through an alias of the query that has the name of
the first association. It is now joined through the alias of the
join before it.
- A listener of the query events that added criteria on an association
to find or findAll with an example, findWhere or findAllWhere made
the query fail, because the join was left to a query that is not
executed. The joins are made after the event.
- find and findAll with an example, findWhere, findAllWhere and list
ignored a join type in the fetch argument. Since a sort joins with a
left join, a sort by the association no longer left out the instances
without it either. They apply the join type now, before the sort,
which uses the join; list restricts the query with a subquery.
The same review found issues that 7.0.x had as well, in what this
branch changes or documents:
- order('contract.id') in a criteria query, on a hasOne, was left to
Hibernate as a foreign key read. Hibernate has no foreign key for it
and joins the association with an inner join, which left out the
players without a contract. Only the identifier of an association
whose foreign key the queried class has, a many-to-one in Hibernate,
is read without a join; any other is joined with a left join, like
the other sorts.
- A paged criteria query sorted by a map of properties failed with a
ClassCastException before the map reached the code that sorts by it.
- A projection of a property of an embedded component of an
association, such as 'ground.address.city', dropped the alias of the
join of the association and failed.
- A right join requested for a collection that criteria on the
collection join returned null for each element that no instance has.
It is joined with an inner join now, as a collection that nothing
else joins already was.
- A join type requested for a collection of a basic type was ignored.
It is applied as for a collection of entities, also by count,
updateAll and deleteAll.
- The associations before a collection on a requested join path, such
as team in team.players.contract, were neither joined nor fetched.
They are joined and fetched, as the upgrade notes say.
The fetching guide and the upgrade notes now say that a right join of
an association to a single instance puts null in the list once for all
its unmatched rows while count counts each of them, and that the MySQL
check of updateAll and deleteAll follows the dialect.
WhereQueryWithAssociationSortSpec gains a nested sort whose data orders
differently by club name, club id, team name and player, so that a sort
through the wrong join cannot pass.
jdaugherty
left a comment
There was a problem hiding this comment.
Pushed two commits for the review of 1ab83ab. The head is 0c81f00.
@jamesfredley: both points are fixed in 688440e, and the details are in the threads. A listener's criteria on an association now get their joins after the PreQueryEvent, and find and findAll with an example, findWhere, findAllWhere and list apply a JoinType in their fetch argument, before a sort by the association, which uses that join.
A second review of the branch, with each finding run against 7.0.x, found seven more regressions. The same commit fixes them:
deleteAllwith an inner join of a collection that has a join table deleted no instance and removed the join rows of the instances it should have deleted. Hibernate deletes the join rows first, with the same restriction. The identifiers are now selected first.updateAllanddeleteAllmatched the side of a one-to-one that the other class has the foreign key of, asNose.facewithstatic belongsTo = [face: Face], by comparing the two primary keys, and changed other instances thanlistreturns.- A sort by a collection that has a join table and multi-tenant elements produced SQL that no database accepts: Hibernate combines the condition that keeps the collection from being fetched with the tenant filter of the join table. The condition is now part of the where clause.
- A sort by
players.namein a query that fetchedreserves, a collection of the same class, filledreserveswith the players. The sort now fetches its collection as well when the query fetches another one. join('club', JoinType.LEFT)followed byfetch: [club: 'join']lost the left join for the criteria onclub, as on 7.0.x it did not.- A paged criteria query sorted through an alias, as
sort: 't.club.name', lost its sort. - A nested sort path was resolved through an alias of the criteria that has the name of the first association on the path.
0c81f00 fixes older issues that the review found in what this branch changes or documents: order('contract.id') on a hasOne inner-joined, a paged criteria query with a map of sort properties failed, a projection into an embedded component of an association failed, a right join of a collection that criteria join returned null rows, a join type for a collection of a basic type was ignored, and the associations before a collection on a requested path were not fetched. It also corrects two statements in the docs.
50 of the new cases fail on 1ab83ab. The whole test suites of grails-data-hibernate5-core and grails-datamapping-core pass, and so does codeStyle for both.
@matrei: both commits change results on 7.0.x in a few more places, and sections 22 and 23 of the upgrade notes describe each of them. grails-datamapping-core gains getSupersededJoinTypes() on the detached criteria, so that a join type that a later fetch mode replaced still reaches the query. The PR description is updated.
jamesfredley
left a comment
There was a problem hiding this comment.
Not approved. Sorting by an empty bag drops the root row when another bag is fetched. A list that asks to fetch a collection with an inner join returns the row without initializing that collection.
A sort by a property of a collection fetches the collection when the
query fetches another collection, because Hibernate otherwise reads the
elements of the fetched collection from the columns of the sort's join.
Hibernate does not support fetching more than one bag, a collection
declared as a Collection, in a query, so for two bags the sort joined
its collection with an inner join instead. That silently left out the
instances whose sorted bag is empty:
Squad.where { ... }.join('substitutes').list(sort: 'regulars.name')
returned no squad without regulars, while the same query without the
sort returned it. 7.0.x, which always sorted with an inner join, left
them out as well.
Such a query now throws an UnsupportedOperationException that names
both collections and says to declare one of them as a Set or a List,
or not to fetch the other one. A sort by a bag in a query that fetches
no other bag keeps the instances whose bag is empty, as before. The
where query guide and the upgrade notes describe the limitation.
…llection A requested join type decides which instances a query returns, and the fetch loads the association with them. A fetched collection holds all of its elements: a join type never leaves elements out of it, which would leave a collection in the session that lacks some of them. - list ignored the fetch argument's join type, and since this branch only restricted the query with it: Team.list(fetch: [players: JoinType.INNER]) returned the teams with players but did not fetch players. list now fetches the association with the requested join type. A path through a collection is fetched with outer joins and restricted with a subquery, an inner or a right join is also applied as that subquery so that the count of a paged list keeps it, and a query that fetches a collection returns each instance once. The count drops the fetches, which Hibernate rejects in a count. Hibernate does not support right joins in criteria queries, so list still applies JoinType.RIGHT like JoinType.INNER. - A join type requested for a path through a collection, as fetch: ['players.contract': JoinType.INNER], restricted the query but did not fetch the collection. Every association before the last one on a requested path is now fetched, the collection with all of its players, also those without a contract. A criteria query and a query by example return a team once for each fetched player, as for any fetch of a collection. A JoinType.LEFT for such a path fetches the path as well. - list reuses a fetch of an association for a sort by it. FetchJoinTypeSpec defines the behavior as a chart of fetch arguments, the teams that they return and what they fetch, and checks every row with eight kinds of query. AssociationSortSpec does the same for a sort by a collection while another set or bag is fetched. The fetching guide and the upgrade notes describe it.
jdaugherty
left a comment
There was a problem hiding this comment.
Pushed two commits for the review of 0c81f00. The head is 1313011.
- 5e6dc8f: a sort by a bag in a query that fetches another bag now throws an
UnsupportedOperationExceptionthat names both collections, instead of leaving out the squad. Keeping the squad would need the sort to fetchregularsas well, which Hibernate does not do for two bags in one query, and a join that does not fetch it lets Hibernate fillsubstituteswith the regulars. A sort by a bag while no other bag is fetched keeps a squad whose bag is empty. - 1313011:
listfetches the association that itsfetchargument requests a join type for, with that join type, soTeam.list(fetch: [players: JoinType.INNER])returns the teams that have players and initializesplayersin the same query. The commit also settles what a join type means for a path through a collection: it decides which instances the query returns, and the collection on the path is fetched with all of its elements.fetch: ['players.contract': JoinType.INNER]returns the teams with a player who has a contract, each with all of its players. A fetch that left out the players without a contract would leave that partial collection in the session. This applies to every kind of query.
The new FetchJoinTypeSpec checks this as a chart of fetch arguments, the teams that they return and what they fetch, for eight kinds of query; 28 of its 64 cases fail on 5e6dc8f. AssociationSortSpec has the same kind of chart for a sort by a set or a bag while the query fetches nothing, a set or a bag. The whole test suite of grails-data-hibernate5-core passes, and so does codeStyle.
@matrei: both commits change results on 7.0.x. Sections 22 and 23 of the upgrade notes describe them, the fetching and where query guides are updated, and so is the PR description.
A sort whose first segment is neither an alias the query created nor a property of the entity was ignored, so
list(sort: 'c1.name') with no alias c1 returned rows in an arbitrary order and hid a misspelled or missing alias.
It now throws IllegalArgumentException("Invalid sort property"), in the order of a query (where queries, dynamic
finders, criteria) and in list(sort:) alike, as Hibernate 7 does. A property that exists but is not an association
keeps being ignored, as before. UndefinedAliasSortSpec covers a where query with and without a criterion, one that
defines another alias, a plain list, a dynamic finder, a list that does not ignore case and an order of a detached
criteria; the where-query guide says the sort fails and still points a sortable table at the association path.
Claude-Session: https://claude.ai/code/session_01Dmki4RzWwreEC2GkkyFnvL
jamesfredley
left a comment
There was a problem hiding this comment.
A list that asks to fetch a collection with an inner join now initializes it. Not approving. Fetching one bag while sorting by another bag throws, and the page total counts the joined rows instead of the distinct roots.
| public static void checkSortedCollection(Criteria criteria, String associationPath) { | ||
| String otherBag = findOtherFetchedCollection(criteria, associationPath, true); | ||
| if (otherBag != null) { | ||
| throw new UnsupportedOperationException("Cannot sort by a property of [" + associationPath + "] of [" + getRootCriteria(criteria).getEntityOrClassName() + |
There was a problem hiding this comment.
Input: an author with one book and one award, listed with a fetch of awards and a sort by books.title, where both collections are bags. The query throws even though only one bag was asked to be fetched. Keep the author row and the requested fetch, and leave the sorted bag as a non-fetching join.
There was a problem hiding this comment.
The exception stays, for a reason that one book and one award cannot show. A join that does not fetch books still returns a row for each book, and awards is fetched from those rows. A bag cannot tell a repeated row from a repeated element, so an author with two books and two awards gets awards as [A3, A3, A4, A4]. 7.0.x does this in a where query and a dynamic finder, and this branch did it in list, the one kind of query without the check. Hibernate's PersistentBag.readFrom says the same ("if we load this collection from a cartesian product the multiplicity would be broken"), and it is what MultipleBagFetchException guards against.
0c312b0 adds the check to list. The message and the docs now give this reason, and say to declare both collections as a Set or a List, or not to fetch the other one. 8c4df83 applies it when either collection is a bag, since one bag is enough: with books as a Set and awards still a bag, the same author gets four awards as well. A sort by an association to a single instance, and a query that fetches the collection that it sorts by, are not rejected. AssociationSortSpec covers list, a paged list and a join type in the fetch argument, and has the combinations as a chart with two elements in each collection, since one element cannot show a repeated row.
|
As discussed, I pushed b4e50b4 on top of 1313011: a sort by an alias the query does not define now throws |
…in and keeps the instances whose association is null or empty list() and a paged list applied the fetch mode join as an inner join that did not fetch, so Team.list(fetch: [club: 'join']) left out the teams without a club and did not load the club, while a where query, a dynamic finder and a criteria query returned every team and fetched the club. The fetch mode join now fetches with a left join, as the join type LEFT does; only a join type asks for the instances to be left out. FetchJoinTypeSpec has the fetch mode join on an association that can be null and on a collection that can be empty for every way to run the query. Claude-Session: https://claude.ai/code/session_01Dmki4RzWwreEC2GkkyFnvL
|
@jdaugherty on the |
jamesfredley
left a comment
There was a problem hiding this comment.
Not approving. A paged list that names its root alias throws when the total is read, and fetching one collection while filtering another collection of the same type fills the fetched collection with the wrong rows.
The query that counts a paged list was created without the root alias
of the query that it copies, so a criterion that names the alias, such
as eq('t.name', ...) on new DetachedCriteria(Team, 't'), threw when
totalCount was read.
A list that fetches a collection returns each instance once, but the join of a sort by another collection made its total count the joined rows. Any other list returns a row for each element of a collection that it sorts by and pages through those rows, so its total still counts them: a count of the instances would put pages out of reach.
list was the one kind of query without this check. The join of the sort returns a row for each element of the sorted bag, and a bag cannot tell a repeated row from a repeated element, so an author with two books and two awards was loaded with four awards. That is also why Hibernate refuses to fetch more than one bag. The exception and the docs now give this reason. They also say to declare both collections as a Set or a List, because a bag that is left next to the join still holds its elements more than once.
Hibernate keeps a left join that has a restriction from fetching its collection, but the join still takes a collection column suffix, so the next collection that the query fetches was read from its columns. With players filtered and reserves fetched, both of the same class, reserves was initialized with the rows of players. An inner join takes no suffix. It returns the same instances unless the criteria accept a missing element, as isNull does, which the upgrade notes and the fetching guide describe.
The check covered two bags only, but one bag is enough. With a set sorted and a bag fetched, the join of the sort repeated the rows of the bag, so a bag with two elements was loaded with four. With a bag sorted and a set fetched, the sort fetched the bag as well, with the same result, where 7.0.x left it to be loaded when it was read. A collection that the query fetches anyway is only rejected next to another bag, because the sort makes no join for it, and a sort by an association to a single instance is not affected. The sort chart in AssociationSortSpec now has two elements in each collection, since one element cannot show a repeated row.
jdaugherty
left a comment
There was a problem hiding this comment.
Pushed six commits for the reviews of 1313011 and 1e47513. The head is 8c4df83.
- 9f2c781: the count of a paged list keeps the root alias of the query. See the thread on
HibernateQuery. - f7ee8a3: a filtered collection no longer fills a collection that the query fetches. See the thread on
AbstractHibernateQuery. - 0c312b0 and 8c4df83: the exception stays and gives its reason. It now also applies to
list, and to a sort by one collection next to a fetch of another when either of them is a bag. See the thread onGrailsHibernateUtil. - 8df78b0, for the page total in the review of 1313011: a total now counts what the list pages through. A list that fetches a collection returns each instance once, so its total counts instances, also when a sort joins another collection. A list that only sorts by a collection returns a row for each element, and
maxandoffsetpage through those rows, so its total keeps counting them: two squads with three players have 3 rows, and a total of 2 would put the last row out of reach. That is the same forlist, a where query and a criteria query, and 7.0.x counts those rows too.
@borinquenkid: deb2784 adds b4e50b4 and 1e47513 to sections 22 and 23 of the upgrade notes.
AssociationSortSpec and AssociationJoinTypeSpec cover the changes, and 21 of their cases fail on 1e47513. The whole test suite of grails-data-hibernate5-core passes, and so does codeStyle.
| public static void checkSortedCollection(Criteria criteria, String associationPath) { | ||
| String otherBag = findOtherFetchedCollection(criteria, associationPath, true); | ||
| if (otherBag != null) { | ||
| throw new UnsupportedOperationException("Cannot sort by a property of [" + associationPath + "] of [" + getRootCriteria(criteria).getEntityOrClassName() + |
There was a problem hiding this comment.
The exception stays, for a reason that one book and one award cannot show. A join that does not fetch books still returns a row for each book, and awards is fetched from those rows. A bag cannot tell a repeated row from a repeated element, so an author with two books and two awards gets awards as [A3, A3, A4, A4]. 7.0.x does this in a where query and a dynamic finder, and this branch did it in list, the one kind of query without the check. Hibernate's PersistentBag.readFrom says the same ("if we load this collection from a cartesian product the multiplicity would be broken"), and it is what MultipleBagFetchException guards against.
0c312b0 adds the check to list. The message and the docs now give this reason, and say to declare both collections as a Set or a List, or not to fetch the other one. 8c4df83 applies it when either collection is a bag, since one bag is enough: with books as a Set and awards still a bag, the same author gets four awards as well. A sort by an association to a single instance, and a query that fetches the collection that it sorts by, are not rejected. AssociationSortSpec covers list, a paged list and a join type in the fetch argument, and has the combinations as a chart with two elements in each collection, since one element cannot show a repeated row.
🚨 All tests passed but jobs failed 🚨Failed Jobs without Test Failures❌ CI / Spring Security Config Functional Tests (annotation) 🏷️ Commit: 8c4df83 Rerun ControlsClick the checkbox to trigger a rerun: 🔲 Rerun jobs Learn more about TestLens at testlens.app/docs. |
Description
Fixes #15589.
Sorting a where query by a property of an association failed with a
QueryExceptionwhen the query also had criteria on that association:The criteria join
clubunder a generated alias, such asclub_alias0, andAbstractHibernateQueryrecords the join under both the association path and the alias.order()found the join, but added the order with the association path, which Hibernate cannot resolve against the root entity. The sort only worked with an explicit alias defined in the where query, because then the sort property already started with the alias. The order is now added with the alias of the join, asclub_alias0.name.The same sort already worked when the query had no criteria on the association, and the Hibernate 7 version of
WhereQueryWithAssociationSortSpecon 8.0.x already expects it to work.A sort by a path through more than one association, such as
team.club.name, failed whether or not the where query joined the nested association, becauseorder()split the path at its first dot only.order()now walks the path one association at a time. It reuses a join the criteria registered under the full path, such asteam.club, and joins any other association on the path for the sort, registering that join like any other. A sort through an explicit alias, such ast.club.name, works the same way. Because the sort join is registered instead of created anew for every order, a sort by two properties of the same association no longer fails withduplicate association path.Sorting by an explicit alias produces the same order as before, and so does sorting by the association's id, such as
club.id, in a query with criteria onclub, which now goes through the join instead of the foreign key.Sorting no longer leaves out rows
An association that was joined only for a sort was joined with an inner join, so the sort left out the instances whose association is
null.Team.list(sort: 'club.name')did not return the teams without a club,Team.findAllWhere([name: 'A team without a club'], [sort: 'club.name'])did not find such a team at all, and thetotalCountof a paged list did not count them. A sort should not change which rows a query returns, so the association is now joined with a left join. This applies to where queries, detached criteria and dynamic finders, and tolist,findandfindAllwith an example,findWhere,findAllWhereand a criteria query that is passed pagination arguments.fetch: [club: JoinType.INNER]does, is used for the sort as well. Before, the sort ignored a requested join type and always created an inner join.Team.where { ... }.sort('club.name'), is applied before the arguments oflistare read and before criteria passed tolistare added. A join type thatfetchrequests there is therefore used for a stored sort as well, and criteria passed tolistjoin the association with their own join type, as they do without the sort. Those criteria can also join it through an alias, as increateAlias('club', 'c'), which failed after a stored sort, and the sort can be by that alias.orderin a criteria query can sort by a property of an association too, as inTeam.createCriteria().list { order('club.name') }, also by a path through more than one association and inside the criteria of an association. It failed withcould not resolve property, and with aQuery.Orderit sorted by a property of the queried class instead. It joins with a left join, and uses a join that the criteria make for the association. The orders are added once the criteria are built, so an order also uses a join type that the criteria or thefetchargument oflistrequest for its association, and does not keep a collection from being fetched that they ask to fetch, whether the request comes before or after the order. An order by the identifier of an association, such asclub.id, makes no join and reads the foreign key, as it did before this PR, so it can still be combined with a projection that groups by the same path or is distinct on it. Inside the criteria of an association, aQuery.Orderfor a single property is resolved against that association too, as an order by a property name is. An order by the identifier of ahasOne, asorder('contract.id'), is joined with a left join as well: Hibernate has no foreign key to read it from, and joined it with an inner join, also before this PR.team.club.idreturns a player whose team has no club, as it did before this PR, when onlyteamwas joined andclub.idwas read from its foreign key.players.name, fetched the collection, and a row limit left elements out of it: withmax: 2, a team came back with two of its three players. A sort now joins a collection without fetching it, unless the query asks for the collection to be fetched, and the collection is loaded when it is read, as it was before this PR. A collection that the query asks to fetch is fetched with and without the sort. Before this PR, the inner join of the sort took the place of the fetch. When the query fetches another collection, a sort by a collection fetches its collection as well: Hibernate gives a join of a collection that it does not fetch the column aliases of the next collection that it fetches, and fills that one with the elements of the other when both hold elements of the same class. Hibernate does not support fetching more than one bag, a collection declared as aCollection, in a query, so when both are bags the query throws anUnsupportedOperationExceptionthat names both collections, instead of leaving out the instances whose sorted bag is empty, as 7.0.x did. The condition that keeps a collection from being fetched is part of the where clause, not of the join condition, which Hibernate combines with the filter of a collection that has a join table, such as the tenant filter of discriminator based multi-tenancy, into SQL that no database accepts.This changes results on 7.0.x, so section 22 of the upgrade notes describes it, including how to leave the instances out as before. #16863 makes the same change for Hibernate 7 on 8.0.x, where the two versions then return the same rows for these sorts. Apart from this, only queries that failed before behave differently.
A requested join type is applied without a sort or criteria
A where query, a detached criteria, a dynamic finder or a criteria query can request a join type for an association, with
fetch: [club: JoinType.INNER]orjoin('club', JoinType.INNER). In a where query, a detached criteria and a dynamic finder, the type was only used when criteria, or with this PR a sort, joined the association. Otherwise the association was fetched with the outer join of its fetch mode:Team.where { ... }.list(fetch: [club: JoinType.INNER])returned the teams without a club, and the same query withsort: 'club.name'did not, so a sort still changed the rows of such a query. A requested join type other thanLEFTis now applied when the query is executed, whether or not anything else in the query uses the association.JoinType.INNERleaves out the instances whose association isnull, fromlist,get,find,countand thetotalCountof a paged list, and still fetches the association.JoinType.RIGHTleaves out the same instances and fetches the association. As a right join does, it also has a row for each associated instance that none of the queried instances refers to:listreturnsnullfor such a row andcountcounts it, unless the criteria of the query exclude it.fetch: ['team.club': JoinType.INNER], was ignored without criteria on the path. Every association on it is now joined and fetched. A path through a collection, as infetch: ['players.contract': JoinType.INNER], restricts the query in a subquery, because a join through a collection returns an instance once for each element:countand thetotalCountof a paged list count each instance once. The collection on the path is fetched with outer joins and holds all of its elements, also the players without a contract: the join type decides which teams the query returns and never leaves elements out of a fetched collection, which would leave a partial collection in the session. A where query and a dynamic finder return each team once, and a criteria query and a query by example once for each fetched player, as for any fetch of a collection. AJoinType.LEFTfor such a path fetches the path too..join('club', JoinType.INNER).list(fetch: [club: 'lazy'])returns the teams without a club, as it did before this PR. The same holds forjoin('club')orselect('club')afterjoin('club', JoinType.INNER), on a where query and in a criteria query, and for thefetchargument of a criteria query. The join that criteria on the association make keeps the requested join type, as it did before this PR, soTeam.where { club.name == 'Arsenal' || name == 'Unattached' }.join('club', JoinType.LEFT).list(fetch: [club: 'join'])returns the team without a club.joinor thefetchargument oflistrequests it. Thefetchargument is read after the criteria have been applied, so it was not used for such a join:Team.where { club.name == 'Chelsea' || name == 'Unattached First Team' }.list(fetch: [club: JoinType.LEFT])left out the team without a club.join('team.club', JoinType.LEFT)is used for the join that criteria onteam.clubmake, which was an inner join whatever was requested, and a join type requested forclubis no longer used forteam.club. Criteria on a nested association can also be used more than once, which failed withduplicate association path.join('club', JoinType.INNER)set a fetch mode like the one-argumentjoin, and aJoinTypein thefetchargument oflistmatched no fetch mode, so the association was not fetched at all. Both now join and fetch with the requested type. They are applied once the criteria are built, so an association that the criteria have a block for keeps the join of that block, as does one that the criteria join through an alias, as increateAlias('t.club', 'c').JoinType.LEFTfor it uses that join type for the join of the criteria, and no longer fetches the collection through that join, whose rows only have the elements that the criteria match. Withjoin('players', JoinType.LEFT)the collection was initialized with those elements only, and with thefetchargument it would have been since this PR uses the argument for the join of criteria. The collection is now loaded when it is read, with all of its elements.updateAllanddeleteAllapply a requested join type too, so they update or delete the instances thatlistreturns.Team.where { ... }.join('club', JoinType.INNER)listed and counted the teams with a club, butdeleteAllon it deleted the teams without one as well. An update and a delete cannot join, so each requested join is applied to them as a restriction: a subquery that selects from the entity of the first association on the path and joins the rest of it. MySQL, and MariaDB before version 10.3, do not allow an update or a delete to select from the table that it modifies. On these databases a join that leads back to the queried class, such as theparentof a category, is rejected with anUnsupportedOperationExceptionthat says to restrict the query with criteria instead. The check follows the dialect: a MySQL dialect, or a MariaDB dialect older thanMariaDB103Dialect.deleteAllselects the identifiers first when the path starts with a collection that has a join table, because Hibernate deletes the join rows before the instances, with the same restriction, which then finds no instance. A one-to-one whose foreign key the other class has is matched through the property that refers back.list,findWhere,findAllWhere, andfindandfindAllwith an example, apply aJoinTypein theirfetchargument too, before a sort by the association, which uses that join. They ignored it, and since a sort joins with a left join, a sort by the association no longer left out the instances without it either.listfetches the association with the requested join type, returns each instance once when it fetches a collection, and appliesRIGHTlikeINNER, as Hibernate does not support right joins in criteria queries. The'join'fetch mode oflistis unchanged, which belongs to the open question aboutfetchinlist()above.hasMany = [tags: String], too. It was ignored.nullfor each element that no instance has.teaminteam.players.contract, are joined and fetched, and so is the collection.findandfindAllwith an example,findWhereandfindAllWhere. The joins that those criteria need are made after the event; they were left to a query that is not executed, and the query failed.This also changes results on 7.0.x. Section 23 of the upgrade notes describes it, and the fetching guide now documents the join types.
A criteria query that is passed pagination arguments can now also be sorted by a path through more than one association.
Player.createCriteria().list(sort: 'team.club.name', max: 10) { ... }failed whentotalCountwas read, and withduplicate association pathwhen the criteria usedteam. The sort now reuses a join that the criteria have for an association path, and creates any other from the root criteria with its whole path, which is how the count recreates it. A join that the criteria make through an alias, as increateAlias('t.club', 'c'), is reused as well. It can also be sorted through an alias that the criteria give an association, assort: 't.club.name'withcreateAlias('t.club', 'c'), and by a map of sort properties, which failed with aClassCastException. An association after the first one on a sort path, or on a requested join path, is joined through the alias of the join before it, so that Hibernate does not read the path through an alias of the criteria that has the name of its first association.listwith a map of sort properties, such asTeam.list(sort: ['club.name': 'asc', name: 'desc']), sorted by the last entry only, because each entry replaced the orders of the query. It now sorts by every entry. Hibernate 7 on 8.0.x already does. The same holds for a default sort that the mapping declares for more than one property, of whichlistapplied the last one only. An association is joined once for all the sort properties that use it:listjoined it once for each, andfindandfindAllwith an example,findWhereandfindAllWherefailed withduplicate association path.The "Sorting with Associations" section of the where query docs said that this sort fails and that an explicit alias is required. It now shows sorting by the association path, notes that the path can cross more than one association, that sorting does not change which rows a query returns, and that a sort by an alias that the query does not define is ignored.
Tests
WhereQueryWithAssociationSortSpecasserted theQueryException. It now checks that a where query with criteria onclubcan be sorted byclub.name:wherecalls andfetch: [club: 'join'].Without the change, these cases fail with the
QueryException. It also checks that the order is as before when sorting byclub.id, by an explicit alias, and byclub.namein a query without criteria onclub.For paths through more than one association, it checks sorting
Playerbyteam.club.namewith criteria onteam.club, with criteria onteamonly, and without criteria on either, and byt.club.namethrough an explicit alias ofteam. It also checks a sort by bothclub.nameandclub.id. These cases failed before the change.For the left join, it checks with a team that has no club that sorts by
club.name,club.id,team.club.nameandteam.club.idkeep the team or its player, also through an explicit alias, with criteria onteam, and in thetotalCountof a paged list. It also checks that a join type requested withfetchis used, and that a criterionclub != nullleaves the team out.Team.clubis nullable in the fixture for that.For a sort stored on the query with
sort, it checks that the team without a club is kept, that a join type requested withfetchinlistorfindis used, also forteam.club.nameand in thetotalCountof a paged list, and that criteria passed tolistreturn the same rows with and without the sort. Seven of these cases fail without the join being made when the query is executed.The new
AssociationSortSpecchecks the same forlist,findAllwith an example,findWhere,findAllWhere, a criteria query with pagination arguments, and a dynamic finder. It also checks a paged criteria query sorted byteam.club.name, with and without criteria onteamand with criteria that jointeam.clubthrough an alias, which failed before, thatlistapplies every property of a default sort, a sort by two properties of one association, andorderin a criteria query, including the sequence of several orders, an order that comes before the criteria or the alias of its association, an order by the identifier of an association together with a projection on it, and aQuery.Orderinside the criteria of an association.Both specs check a sort by a property of a collection as well: that it keeps a team without players, and that it does not fetch the collection, with a row limit and for a single result, so that no player is left out of it, unless
fetchasks for the collection.56 of the 71 cases of
AssociationSortSpec, and 24 of the 59 cases ofWhereQueryWithAssociationSortSpec, fail on c0c5a80, the commit before the left join.The new
AssociationJoinTypeSpecchecks a join type requested without a sort or criteria on the association:'join',LEFT,INNERandRIGHTwithlist, including that the association is fetched, the same rows with and without a sort,count, thetotalCountof a paged list,find,get, a dynamic finder,team.club, theplayerscollection, including that an inner and a right join fetch it, also when the query is sorted by it, a path through the collection, which must not leave elements out of it, a fetch mode requested after a join type, a subquery, also one that has criteria on the association it requests a join type for, and a projection. It checks the same for a criteria query, withjoinand with thefetchargument oflist, includingcount,get, thetotalCountof a paged list, a sort, a collection, a nested path and an association the criteria have a block for or join through an alias. It also checks that a join type thatfetchrequests is used for the join of criteria and of a projection, a join type for a nested association, a fetch mode requested after a join type, and that a path through a collection returns and counts each instance once. For a criteria query that is ordered by an association or a collection, it checks that a join type and a fetch thatjoinor thefetchargument request are applied, before and after the order. It also checks that a left join requested for a collection that a where query has criteria on does not fetch the collection, and thatupdateAllanddeleteAllupdate or delete the instances thatlistreturns, for an association, a collection, a path through either, and an association whose key the associated class has. 101 of its 135 cases fail without the changes. 28 cases of the two specs fail on 9c59ac4, where an order in a criteria query joined its association before the rest of the criteria was known, and nine fail on e8f7036, whereupdateAllanddeleteAlldid not apply a requested join type.The new
MultiTenancyRequestedJoinSpecchecks, with discriminator based multi-tenancy, that an inner or a right join requested for a collection leaves out an instance whose elements all belong to another tenant, from a where query, a criteria query andcount. Six of its seven cases fail on c5b0edb.DetachedCriteriaJoinTypeSpecingrails-datamapping-corechecks that a laterjoinorselectreplaces a recorded join type and keeps it as one that the fetch mode replaced, also in a copy of the criteria.The new
RequestedJoinBulkOperationDialectSpecchecks, with the MySQL and MariaDB dialects, thatupdateAllanddeleteAllreject an inner join requested for an association to the queried class, that the MariaDB 10.3 and H2 dialects apply it, that a join to another class is applied on MySQL, and that criteria on the association restrict the update there. Four of its nine cases fail on cc58a3b.A second review, with each finding run against 7.0.x, added cases for:
deleteAllandupdateAllthrough a collection that has a join table and through the side of a one-to-one that the other class has the foreign key of; a sort by a collection that has a join table and multi-tenant elements, with each kind of query; a sort by a collection while the query fetches another collection of the same class, and the exception when both are bags; a fetch mode requested after a join type, with criteria on the association; a paged criteria query sorted through an alias, by a map, and by a nested path when an alias has the name of its first association; a join type in thefetchargument oflist,findWhere,findAllWhere, andfindandfindAllwith an example, with and without a sort; a collection of a basic type; the associations before a collection on a path; a right join of a collection that criteria join;order('contract.id')on ahasOne; and a projection into an embedded component of an association. The newPreQueryListenerAssociationCriteriaSpecchecks criteria on an association that a listener adds. 50 of these cases fail on 1ab83ab.WhereQueryWithAssociationSortSpecalso sorts players whose order differs by club name, club id, team name and player name, so that a sort through the wrong join cannot pass.The new
FetchJoinTypeSpecdefines the behavior of a requested join type as a chart: forplayers,players.contractandclubwithINNER,RIGHTandLEFT, which teams the query returns, what it fetches, that a fetched collection holds all of its elements, whether a team is returned once or once for each fetched player, and thetotalCountof a paged list. It checks every row with eight kinds of query: a where query with thefetchargument and withjoin, a dynamic finder, a criteria query withjoinand with thefetchargument,findAllwith an example,listand a pagedlist. 28 of its 64 cases fail on 5e6dc8f.AssociationSortSpechas the same kind of chart for a sort by a set or a bag while the query fetches nothing, a set or a bag.These pass with the change:
grails-data-hibernate5-core, including the data TCK, and ofgrails-datamapping-core;codeStyleforgrails-data-hibernate5-coreandgrails-datamapping-core.