Search code examples
grailsgrails-ormhibernate-criteria

Getting specific columns from relationships with group by using criteria query


I'm having issues formulating a criteria query.

I'm looking at getting distinct Season(id, name), League(id, name) for a specific user, so that I can turn it into a indented list:

  • SeasonA
    • LeagueA
    • LeagueB
    • ...
  • SeasonB
    • LeagueA
    • LeagueC
    • ...

Domain classes:

class League {

    String name
    User user

    static hasMany = [games:Game]  
}

class Season {

    String name
    User user

    static hasMany = [games:Game]
}

class Game {

    Season season
    League league
}

class User {

    String username
    String password
    String email
}

Criteria query:

def id = 1
def seasonList = Game.createCriteria().list{
    season{
        projections{
            property('id')
            property('name')
            groupProperty('id')
        }
        user{
            eq 'id', id.toLong()
        }
    }
    league{
        projections{
            property('id')
            property('name')
            groupProperty('id')
        }
    }
}   

Resulting SQL:

select
    league_ali3_.id as y0_,
    league_ali3_.name as y1_,
    league_ali3_.id as y2_
from
    game this_
inner join
    league league_ali3_
        on this_.league_id=league_ali3_.id
inner join
    season season_ali1_
        on this_.season_id=season_ali1_.id
inner join
    user user_alias2_
        on season_ali1_.user_id=user_alias2_.id
where
    (
        (
            user_alias2_.id=?
        )
    )
group by
    league_ali3_.id

I can see that it's missing the season properties and it's not grouping by season either.

I noticed that the last closure seems to be what it will select. If I switch the season and league closures, I get the season fields instead.

I decided to try aliases:

def seasonList = Game.createCriteria().list{
    createAlias('season','s')
    createAlias('league','l')

    season{
        projections{
            property('s.id')
            property('s.name')
            groupProperty('s.id')
        }
        user{
            eq 'id', id.toLong()
        }
    }
    league{
        projections{
            property('l.id')
            property('l.name')
            groupProperty('l.id')
        }
    }   
}

However, I get a QueryException: duplicate association path: season. Again, the first closure will be the one that will raise the error.

As I wasn't even getting duplicates, I have yet to tackle the distinct part of my query.

Update #2

Final criteria query, with added group by:

def seasonList = Game.createCriteria().list{
            resultTransformer = new org.hibernate.transform.AliasToEntityMapResultTransformer()

            projections {
                season {
                    property('id', 'seasonId')
                    property('name', 'seasonName')
                    groupProperty('id')
                }

                league {
                    property('id', 'leagueId')
                    property('name', 'leagueName')
                    groupProperty('id')
                }
            }

            season { 
                user {
                    eq 'id', id.toLong()
                } 
            }
        }

       def seasons = seasonList
        .groupBy { it.seasonId }
        .collect { seasonId, records -> 
            [
                id: seasonId, 
                name: records.head().seasonName, 
                leagues: records.collect { [id: it.leagueId, name: it.leagueName] }
            ]
        }

Solution

  • A problem is that you've got the projections hierarchy backwards. You need to start at projections and then go down into the associations:

    def id = 1
    def seasonList = Game.createCriteria().list{
        projections {
            season {
                property('id')
                property('name')
            }
    
            league {
                property('id')
                property('name')
            }
        }
    
        season { 
            user {
                eq 'id', id.toLong()
            } 
        }
    } 
    

    You're not using aggregate functions, so you shouldn't need groupProperty().

    Normalizing the results

    The above query returns a flat list, making it a challenge to create an indented list; you'd have to iterate and keep track of when the session changes. We can do better. Let's start by changing the result from a List<List> to a List<Map>:

    def id = 1
    def seasonList = Game.createCriteria().list{
        resultTransformer = new org.hibernate.transform.AliasToEntityMapResultTransformer()
    
        projections {
            season {
                property('id', 'seasonId')
                property('name', 'seasonName')
            }
    
            league {
                property('id', 'leagueId')
                property('name', 'leagueName')
            }
        }
    
        season { 
            user {
                eq 'id', id.toLong()
            } 
        }
    } 
    

    The map result transformer, in this case along with property aliases, makes the query return something like this:

    [
        [seasonId: 1, seasonName: 'Season A', leagueId: 100, leagueName: 'League A'],
        [seasonId: 1, seasonName: 'Season A', leagueId: 200, leagueName: 'League B'],
        [seasonId: 2, seasonName: 'Season B', leagueId: 100, leagueName: 'League A'],
        [seasonId: 2, seasonName: 'Season B', leagueId: 300, leagueName: 'League C']
    ]
    

    So far, the only change in the result is that you now have keys you can use to reference the data. This is much easier on the brain than array indices. The data is still flat (denormalized), but that's easy to take care of:

    def seasons = seasonList
        .groupBy { it.seasonId }
        .collect { seasonId, records -> 
            [
                id: seasonId, 
                name: records.head().seasonName, 
                leagues: records.collect { [id: it.leagueId, name: it.leagueName] }
            ]
        }
    

    This is the outcome:

    [
        [
            'id':1, 'name':'Season A', 'leagues':[
                ['id':100, 'name':'League A'], 
                ['id':200, 'name':'League B']
            ]
        ], 
        [
            'id':2, 'name':'Season B', 'leagues':[
                ['id':100, 'name':'League A'], 
                ['id':300, 'name':'League C']
            ]
        ]
    ]
    

    Now, you can easily work with the nested data to create an indented list. Here's an example in plain Groovy which can easily be adapted to GSP:

    seasons.each { season ->
        println "$season.name"
        season.leagues.each { league -> println "\t$league.name" }
    }
    

    The example above prints the following:

    Season A
        League A
        League B
    Season B
        League A
        League C
    

    Factoring out the normalization

    The functionality can be made more reusable by factoring out into a method and passing in the data and configuration:

    def normalize(List rows, Map config) {
        rows.groupBy { it[config.id] }
        .collect { id, records -> 
            [
                id: id, 
                name: records.head()[config.name], 
                (config.children.key): records.collect { [id: it[config.children.id], name: it[config.children.name]] }
            ]
        }
    }
    

    Then, you can call the method with the query output and a Map telling the method which properties to use.

    def seasons = normalize(seasonList, [id: 'seasonId', name: 'seasonName', children: [key: 'leagues', id: 'leagueId', name: 'leagueName']])