Repository: adamchainz/django-mysql

Issue:
GroupConcat support order by
`GROUP_CONCAT` ( https://mariadb.com/kb/en/mariadb/group_concat/ ) supports `ORDER BY`. I guess that could be useful for someone out there.



Discussion:

Resolving patch:
diff --git a/django_mysql/models/aggregates.py b/django_mysql/models/aggregates.py
--- a/django_mysql/models/aggregates.py
+++ b/django_mysql/models/aggregates.py
@@ -67,7 +67,8 @@ def add_to_query(self, query, alias, col, source, is_summary):
 
     class SQLGroupConcat(SQLAggregate):
 
-        def __init__(self, col, distinct=False, separator=None, **extra):
+        def __init__(self, col, distinct=False, separator=None,
+                     ordering=None, **extra):
             super(SQLGroupConcat, self).__init__(col, **extra)
 
             self.distinct = distinct
@@ -76,6 +77,11 @@ def __init__(self, col, distinct=False, separator=None, **extra):
             # This can/will be improved to SetTextField or ListTextField
             self.field = CharField()
 
+            if ordering not in ('asc', 'desc', None):
+                raise ValueError(
+                    "'ordering' must be one of 'asc', 'desc', or None")
+            self.ordering = ordering
+
         sql_function = 'GROUP_CONCAT'
 
         @cached_property
@@ -90,7 +96,10 @@ def sql_template(self):
             template.append("%(field)s")
 
             if self.separator is not None:
-                template.append(' SEPARATOR "{}"'.format(self.separator))
+                template.append(" SEPARATOR '{}'".format(self.separator))
+
+            if self.ordering is not None:
+                template.append(" ORDER BY %(field)s " + self.ordering.upper())
 
             template.append(")")
 
@@ -115,7 +124,7 @@ class GroupConcat(Aggregate):
         function = 'GROUP_CONCAT'
 
         def __init__(self, expression, distinct=False, separator=None,
-                     **extra):
+                     ordering=None, **extra):
 
             if 'output_field' not in extra:
                 # This can/will be improved to SetTextField or ListTextField
@@ -126,20 +135,37 @@ def __init__(self, expression, distinct=False, separator=None,
             self.distinct = distinct
             self.separator = separator
 
-        @cached_property
-        def template(self):
-            # Constructing a template...
-
-            template = ["%(function)s("]
+            if ordering not in ('asc', 'desc', None):
+                raise ValueError(
+                    "'ordering' must be one of 'asc', 'desc', or None")
+            self.ordering = ordering
 
+        def as_sql(self, compiler, connection, function=None, template=None):
+            connection.ops.check_expression_support(self)
+            sql = ["GROUP_CONCAT("]
             if self.distinct:
-                template.append("DISTINCT ")
+                sql.append("DISTINCT ")
 
-            template.append("%(field)s")
+            expr_parts = []
+            params = []
+            for arg in self.source_expressions:
+                arg_sql, arg_params = compiler.compile(arg)
+                expr_parts.append(arg_sql)
+                params.extend(arg_params)
+            expr_sql = self.arg_joiner.join(expr_parts)
+
+            sql.append(expr_sql)
 
             if self.separator is not None:
-                template.append(' SEPARATOR "{}"'.format(self.separator))
+                sql.append(" SEPARATOR '{}'".format(self.separator))
 
-            template.append(")")
+            if self.ordering is not None:
+                sql.append(" ORDER BY ")
+                sql.append(expr_sql)
+                params.extend(params[:])
+                sql.append(" ")
+                sql.append(self.ordering.upper())
 
-            return "".join(template)
+            sql.append(")")
+
+            return "".join(sql), params


Test patch:
diff --git a/tests/testapp/test_aggregates.py b/tests/testapp/test_aggregates.py
--- a/tests/testapp/test_aggregates.py
+++ b/tests/testapp/test_aggregates.py
@@ -1,7 +1,13 @@
 # -*- coding:utf-8 -*-
+from unittest import skipIf
+
+import django
+import pytest
+from django.db.models import F
 from django.test import TestCase
 
 from django_mysql.models import BitAnd, BitOr, BitXor, GroupConcat
+from django_mysql.test.utils import override_mysql_variables
 from testapp.models import Alphabet, Author
 
 
@@ -102,10 +108,46 @@ def test_separator_big(self):
         concatted_ids = "BIG".join(self.str_tutee_ids)
         assert out == {'tids': concatted_ids}
 
-    def test_order(self):
+    @skipIf(django.VERSION <= (1, 8), "Requires Expressions from Django 1.8+")
+    def test_expression(self):
+        concat = GroupConcat(F('id') + 1)
+        out = self.shakes.tutees.aggregate(tids=concat)
+        concatted_ids = ",".join([
+            str(self.jk.id + 1),
+            str(self.grisham.id + 1),
+        ])
+        assert out == {'tids': concatted_ids}
+
+    def test_application_order(self):
         out = (
             Author.objects
                   .exclude(id=self.shakes.id)
                   .aggregate(tids=GroupConcat('tutor_id', distinct=True))
         )
         assert out == {'tids': str(self.shakes.id)}
+
+    @override_mysql_variables(SQL_MODE="ANSI")
+    def test_separator_ansi_mode(self):
+        concat = GroupConcat('id', separator='>>')
+        out = self.shakes.tutees.aggregate(tids=concat)
+        concatted_ids = ">>".join(self.str_tutee_ids)
+        assert out == {'tids': concatted_ids}
+
+    def test_ordering_invalid(self):
+        with pytest.raises(ValueError) as excinfo:
+            self.shakes.tutees.aggregate(
+                tids=GroupConcat('id', ordering='asceding')
+            )
+        assert "'ordering' must be one of" in str(excinfo.value)
+
+    def test_ordering_asc(self):
+        out = self.shakes.tutees.aggregate(
+            tids=GroupConcat('id', ordering='asc')
+        )
+        assert out == {'tids': ",".join(self.str_tutee_ids)}
+
+    def test_ordering_desc(self):
+        out = self.shakes.tutees.aggregate(
+            tids=GroupConcat('id', ordering='desc')
+        )
+        assert out == {'tids': ",".join(reversed(self.str_tutee_ids))}