Repository: adamchainz/django-mysql

Issue:
GROUP_CONCAT
MySQL's `GROUP_CONCAT` would great to support for aggregation. By passing, or even autodetecting, the base field object, the concatenation could be automatically cast back into a list of the base type values.



Discussion:
Starting point - this gist: https://gist.github.com/ludoo/ca6ed07e5c8017272701


Resolving patch:
diff --git a/django_mysql/models/__init__.py b/django_mysql/models/__init__.py
--- a/django_mysql/models/__init__.py
+++ b/django_mysql/models/__init__.py
@@ -1,4 +1,5 @@
 from django_mysql.models.base import Model  # NOQA
+from django_mysql.models.aggregates import *  # NOQA
 from django_mysql.models.query import (  # NOQA
     ApproximateInt, SmartChunkedIterator, SmartIterator, pt_visual_explain,
     QuerySet, QuerySetMixin
diff --git a/django_mysql/models/aggregates.py b/django_mysql/models/aggregates.py
new file mode 100644
--- /dev/null
+++ b/django_mysql/models/aggregates.py
@@ -0,0 +1,48 @@
+from django.db.models import CharField
+from django.db.models import Aggregate
+from django.db.models.sql.aggregates import Aggregate as SQLAggregate
+from django.utils.functional import cached_property
+
+__all__ = ('GroupConcat',)
+
+
+class GroupConcat(Aggregate):
+    def add_to_query(self, query, alias, col, source, is_summary):
+        query.aggregates[alias] = SQLGroupConcat(
+            col,
+            source=source,
+            is_summary=is_summary,
+            **self.extra
+        )
+
+
+class SQLGroupConcat(SQLAggregate):
+
+    def __init__(self, col, distinct=False, separator=None, **extra):
+        super(SQLGroupConcat, self).__init__(col, **extra)
+
+        self.distinct = distinct
+        self.separator = separator
+
+        # This can/will be improved to SetTextField or ListTextField
+        self.field = CharField()
+
+    sql_function = 'GROUP_CONCAT'
+
+    @cached_property
+    def sql_template(self):
+        # Constructing a template...
+
+        template = ["%(function)s("]
+
+        if self.distinct:
+            template.append("DISTINCT ")
+
+        template.append("%(field)s")
+
+        if self.separator is not None:
+            template.append(' SEPARATOR "{}"'.format(self.separator))
+
+        template.append(")")
+
+        return "".join(template)
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -31,6 +31,9 @@
 # version is used.
 sys.path.insert(0, project_root)
 
+from django.conf import settings
+settings.configure()
+
 import django_mysql
 
 # -- General configuration ---------------------------------------------


Test patch:
diff --git a/tests/django_mysql_tests/models.py b/tests/django_mysql_tests/models.py
--- a/tests/django_mysql_tests/models.py
+++ b/tests/django_mysql_tests/models.py
@@ -20,7 +20,7 @@ def __unicode__(self):
 
 class Author(Model):
     name = CharField(max_length=32, db_index=True)
-    tutor = ForeignKey('self', null=True)
+    tutor = ForeignKey('self', null=True, related_name='tutees')
 
     def __unicode__(self):
         return "{} {}".format(self.id, self.name)
diff --git a/tests/django_mysql_tests/test_aggregates.py b/tests/django_mysql_tests/test_aggregates.py
new file mode 100644
--- /dev/null
+++ b/tests/django_mysql_tests/test_aggregates.py
@@ -0,0 +1,53 @@
+# -*- coding:utf-8 -*-
+from django.test import TestCase
+
+from django_mysql.models import GroupConcat
+
+from django_mysql_tests.models import Author
+
+
+class GroupConcatTests(TestCase):
+
+    def setUp(self):
+        self.shakes = Author.objects.create(name='William Shakespeare')
+        self.jk = Author.objects.create(name='JK Rowling', tutor=self.shakes)
+        self.grisham = Author.objects.create(name='Grisham', tutor=self.shakes)
+
+        self.str_tutee_ids = [str(self.jk.id), str(self.grisham.id)]
+
+    def test_basic_aggregate_ids(self):
+        out = self.shakes.tutees.aggregate(tids=GroupConcat('id'))
+        concatted_ids = ",".join(self.str_tutee_ids)
+        self.assertEqual(out, {'tids': concatted_ids})
+
+    def test_basic_annotate_ids(self):
+        concat = GroupConcat('tutees__id')
+        shakey = Author.objects.annotate(tids=concat).get(id=self.shakes.id)
+        concatted_ids = ",".join(self.str_tutee_ids)
+        self.assertEqual(shakey.tids, concatted_ids)
+
+    def test_separator(self):
+        concat = GroupConcat('id', separator=':')
+        out = self.shakes.tutees.aggregate(tids=concat)
+        concatted_ids = ":".join(self.str_tutee_ids)
+        self.assertEqual(out, {'tids': concatted_ids})
+
+    def test_separator_empty(self):
+        concat = GroupConcat('id', separator='')
+        out = self.shakes.tutees.aggregate(tids=concat)
+        concatted_ids = "".join(self.str_tutee_ids)
+        self.assertEqual(out, {'tids': concatted_ids})
+
+    def test_separator_big(self):
+        concat = GroupConcat('id', separator='BIG')
+        out = self.shakes.tutees.aggregate(tids=concat)
+        concatted_ids = "BIG".join(self.str_tutee_ids)
+        self.assertEqual(out, {'tids': concatted_ids})
+
+    def test_order(self):
+        out = (
+            Author.objects
+                  .exclude(id=self.shakes.id)
+                  .aggregate(tids=GroupConcat('tutor_id', distinct=True))
+        )
+        self.assertEqual(out, {'tids': str(self.shakes.id)})