4

Using JPA Criteria API, I want to group by a column and join the values of another column.

For example, the below is the sql approach and I am looking for the equivalent criteria query (and jpql query) approach.

mysql> select *from GroupConcatenateDemo;
+------+-------+
| Id   | Name  |
+------+-------+
|   10 | Larry |
|   11 | Mike  |
|   12 | John  |
|   10 | Elon  |
|   10 | Bob   |
|   11 | Sam   |
+------+-------+

GROUP BY USING SQL

mysql> select Id,group_concat(Name SEPARATOR ',') as GroupConcatDemo from GroupConcatenateDemo group by Id;

+------+-----------------+
| Id   | GroupConcatDemo |
+------+-----------------+
|   10 | Larry,Elon,Bob  |
|   11 | Mike,Sam        |
|   12 | John            |
+------+-----------------+

Does Criteria Query / JPQL have equivalent of group_concat or is there any other approach i can follow to achieve the above final output.

I have checked and tested both apis, they both seem to provide only concat function which is not same as the SQL group_concat.

Edit -

I Figured out how to register a db function - I could use the GROUP_CONCAT function from Criteria API. For this I had to add a Custom Dialect Class and inform spring(boot) about this class.

package com.mypackage;

import org.hibernate.dialect.MySQL8Dialect;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StandardBasicTypes;

public class CustomMySQLDialect extends MySQL8Dialect {
    public CustomMySQLDialect() {
        super();

        registerFunction(
                "GROUP_CONCAT",
                new StandardSQLFunction(
                        "GROUP_CONCAT",
                        StandardBasicTypes.STRING
                )
        );
    }
} 

And then inform spring boot about this class, in application.properties-
spring.jpa.properties.hibernate.dialect = com.mypackage.CustomMySQLDialect

Its working though but with issues -

  1. I am not able to figure out how to use the SEPERATOR, i want to use a separator other than the default ,(comma).
  2. I also want to use DISTINCT, ORDER BY features of group_concat.
    How do i pass these through criteria api.

Current Situation -.
Currently my group_concat code part of criteria query is something like below -

some other selects... , cb.function("GROUP_CONCAT", String.class, packagesJoin.get("packageName")), some other selects   

and the generated sql part is - GROUP_CONCAT(packages4_.package_name) as col_3_0_,.

And the output is - Package-1,Package-1,Package-2,Package-2

SOF Suggested situation -
like suggested by @jens-schauder (thanks jens) - if i use

cb.function( "group_concat", String.class, cb.concat( root.get("name"), cb.literal(",") )

i.e the code is
cb.function("GROUP_CONCAT", String.class, packagesJoin.get("packageName"), cb.literal(",")),

the generated sql is -

GROUP_CONCAT(packages4_.package_name,
        ',') as col_3_0_,

the output is:

Package-1,,Package-1,,Package-2,,Package-2,

Problem in this approach is - the , in cb.literal(",") is concatenated with the column value. This should not happen and be resolved.

Wanted/Desired Situation - The SQL I want to be generated is -
GROUP_CONCAT(DISTINCT packages4_.package_name ORDER BY packages4_.package_name DESC SEPARATOR ' # ') as col_3_0_,.

And desired output is

Package-2 # Package-1

What more should i add to the criteria query. Any answers will be very much appreciated.... this is quite critical for me.

samshers
  • 1
  • 6
  • 37
  • 84
  • 1
    Hibernate supports only the common function syntax `function(parameter1, parameter2)`. There is no ability to specify additional attributes like `DISTINCT`, `SEPARATOR`. For such complex queries better to use `Native` query. Also, as a workaround, you can create some simple wrapper sql function for `GROUP_CONCAT` to simplify syntaxis. – Eugene Jul 30 '22 at 22:14
  • @Eugene - +1. the reason i am using `Criteria API` is i have many dynamic parameters and this leads to many permutations. So native sql is not an option. Could provide any references to - `you can create some simple wrapper sql function` on how to achieve this. might be this could help me. – samshers Jul 31 '22 at 03:46
  • refered this article but this too does not address the `DISTINCT / ORDER BY / SEPERATOR` Issue - https://vladmihalcea.com/hibernate-sql-function-jpql-criteria-api-query/ – samshers Jul 31 '22 at 05:00

2 Answers2

3

One of the solutions is to create a custom GROUP_CONCAT HQL function that is translated to SQL.
Idea is to create function: group_concat(name, true, ' # ', name, 'DESC')

  • 1: name of the column for aggregation
  • 2: true\false use DISTINCT or not
  • 3: the separator for concatenation
  • 4: column name for ORDER BY
  • 5: sorting type ASC/DESC

Which are translating: GROUP_CONCAT(DISTINCT name ORDER BY name DESC SEPARATOR ' # ' )

PLEASE NOTE: implementation does not handle all possible use cases of the GROUP_CONCAT function, for example not handled limit parameter and several columns for sorting. But it can be extended. Current implementation fully resolves described problem.

1. Extend StandardSQLFunction with logic of handling DISTINCT/ ORDER BY / SEPARATOR parameters

public class GroupConcatFunction extends StandardSQLFunction {

    public static GroupConcatFunction INSTANCE = new GroupConcatFunction();

    public GroupConcatFunction() {
        super("GROUP_CONCAT", StandardBasicTypes.STRING);
    }

    @Override
    public String render(Type firstArgumentType, List arguments, SessionFactoryImplementor factory) throws QueryException {
        return render(arguments);
    }

    @SuppressWarnings("UnusedParameters")
    protected String render(List<Object> arguments) {
        String column;
        String separator = null;
        Boolean distinct = Boolean.FALSE;
        String orderBy = null;

        if (arguments.size() > 0) {
            column = arguments.get(0).toString();
        } else {
            throw new IllegalArgumentException("GROUP_CONCAT should have at least one Column Name parameter!");
        }

        if (arguments.size() > 1) {
            distinct = Boolean.valueOf(arguments.get(1).toString());
        }

        if (arguments.size() > 2) {
            separator = arguments.get(2).toString();
        }

        if (arguments.size() > 4) {
            orderBy = String.format("%s %s", arguments.get(3).toString(), arguments.get(4).toString().replace("'", ""));
        }
        return render(column, separator, distinct, orderBy);
    }

    protected String render(String column, String separator, Boolean distinct, String orderBy) {
        StringBuilder groupConcatFunction = new StringBuilder();
        groupConcatFunction.append("GROUP_CONCAT(");
        if (distinct) {
            groupConcatFunction.append("DISTINCT");
        }
        groupConcatFunction.append(" ").append(column);
        if (orderBy != null) {
            groupConcatFunction.append(" ORDER BY ").append(orderBy);
        }
        if (separator != null) {
            groupConcatFunction.append(" SEPARATOR ").append(separator);
        }
        groupConcatFunction.append(" )");
        return groupConcatFunction.toString();
    }
}

2. Register GROUP_CONCAT function

public class CustomMetadataBuilderContributor implements MetadataBuilderContributor {
    @Override
    public void contribute(MetadataBuilder metadataBuilder) {
        metadataBuilder.applySqlFunction(GroupConcatFunction.INSTANCE.getName(), GroupConcatFunction.INSTANCE);
    }
}

Example of usage:
Preconditions

@Entity
@NoArgsConstructor
@Data
@Table(name = "Group_Concatenate_Demo")
public class GroupConcatenateDemo {
    @Id
    private Long id;

    private Long recid;

    private String name;
}
INSERT INTO Group_Concatenate_Demo (ID, RECID, NAME) VALUES(1, 10, 'Larry')
INSERT INTO Group_Concatenate_Demo (ID, RECID, NAME) VALUES(2, 11, 'Mike')
INSERT INTO Group_Concatenate_Demo (ID, RECID, NAME) VALUES(3, 12, 'John')
INSERT INTO Group_Concatenate_Demo (ID, RECID, NAME) VALUES(4, 10, 'Elon')
INSERT INTO Group_Concatenate_Demo (ID, RECID, NAME) VALUES(5, 10, 'Bob')
INSERT INTO Group_Concatenate_Demo (ID, RECID, NAME) VALUES(6, 11, 'Sam')

JPQL query

public interface GroupConcatenateDemoRepository extends JpaRepository<GroupConcatenateDemo, Long> {
    @Query("SELECT recid, group_concat(name, true, ' # ', name, 'DESC') FROM GroupConcatenateDemo GROUP BY recid")
    List<Object[]> findGroup();
}

Generated sql

    select
        groupconca0_.recid as col_0_0_,
        GROUP_CONCAT(DISTINCT groupconca0_.name 
    ORDER BY
        groupconca0_.name ASC SEPARATOR ' # ' ) as col_1_0_ 
    from
        group_concatenate_demo groupconca0_ 
    group by
        groupconca0_.recid

Criteria API

    public List<Object[]> groupCriteria() {
        final CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
        CriteriaQuery<Object[]> criteriaQuery = criteriaBuilder.createQuery(Object[].class);
        Root<GroupConcatenateDemo> groupConcatenateDemoRoot = criteriaQuery.from(GroupConcatenateDemo.class);

        criteriaQuery.multiselect(groupConcatenateDemoRoot.get("recid").alias("recid"),
                                  criteriaBuilder.function("group_concat", String.class,
                                  groupConcatenateDemoRoot.get("name"),
                                          criteriaBuilder.literal(true),
                                          criteriaBuilder.literal(" # "),
                                          groupConcatenateDemoRoot.get("name"),
                                          criteriaBuilder.literal("DESC")).alias("name"));

        criteriaQuery.where().groupBy(groupConcatenateDemoRoot.get("recid"));

        return entityManager.createQuery(criteriaQuery).getResultList();
    }

Generated sql

    select
        groupconca0_.recid as col_0_0_,
        GROUP_CONCAT(DISTINCT groupconca0_.name 
    ORDER BY
        groupconca0_.name DESC SEPARATOR ' # ' ) as col_1_0_ 
    from
        group_concatenate_demo groupconca0_ 
    where
        1=1 
    group by
        groupconca0_.recid

Output:

[[10,"Larry # Elon # Bob"],[11,"Sam # Mike"],[12,"John"]]
Eugene
  • 5,269
  • 2
  • 14
  • 22
  • 1
    quite cool. going through it. +1 – samshers Aug 01 '22 at 05:10
  • 1
    spring boot users - add this props -> `spring.jpa.properties.hibernate.metadata_builder_contributor: com.mypackage.CustomMetadataBuilderContributor` – samshers Aug 05 '22 at 03:34
  • 1
    are you sure that `criteriaBuilder.literal(true)` regarding passing literal will work. Coz `true` here will be interpreted as `1`. On adding logging, I see this- `arguments.get(1).toString() = 1`. The code is great and working, i can fix this but am surprised how its working at your end.. coz you have the right SQL being generated on you side and on my side the `DISTINCT` won't be added coz of above issue. – samshers Aug 05 '22 at 04:16
  • 1
    I checked code at my spring boot application version 2.7, posted as it is. Queries also posted after execution. So for me working well. You can correct any parameters as you wish)) – Eugene Aug 05 '22 at 07:13
  • Eugene - the above approach worked for me on 2.7.x. But due to other dependencies/reasons I had to downgrade to 2.1.7. But the custom rendering part is not working with 2.1.7 . Is there a way to make the above code work with `2.1.7.RELEASE` too. Can you test at your end. – samshers Sep 11 '22 at 03:42
  • i can see that - `2.1.7.RELEASE` is using `hibernate-core:5.3.10` AND `2.7.0` is using `hibernate-core:5.6.9`. But i am still not sure why it's making such a huge difference. – samshers Sep 11 '22 at 18:05
  • 1
    Hi, Hibernate dependency became 5.3.10.Final too. Contract of `org.hibernate.dialect.function.StandardSQLFunction` was not changed. For me working well. I did not change the source code just downgrade Spring Boot. Hibernate generated the same query as before, with the same output. – Eugene Sep 12 '22 at 21:59
2

You can use CriteriaBuilder.function to call arbitrary SQL functions.

I don't see an easy way to mimic the SEPARATOR ',' syntax. What you could do instead would be to append the separator to the field before invoking group_concat. You'd need to strip away the last ",".

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery cq = cb.createQuery();
Root root = cq.from(Demo.class);
cq.select(
    cb.function(
        "group_concat", 
        String.class, 
        cb.concat( 
            root.get("name"), 
            cb.literal(",")
        )
    )
)

This article mentions that you need to register the function when using it in the select clause.

How to do that is explained in https://stackoverflow.com/a/52725042/66686. It might even allow to create custom SQL which could be used to render the SEPERATOR clause.

Jens Schauder
  • 77,657
  • 34
  • 181
  • 348