Repository: adamchainz/django-mysql

Issue:
FUNCTIONS()
Django 1.8 supports database functions https://docs.djangoproject.com/en/dev/ref/models/database-functions/ . I guess people would like a place to import all the MySQL / MariaDB functions from so they don't have to redefine them in every project!!



Discussion:

Resolving patch:
diff --git a/django_mysql/models/functions.py b/django_mysql/models/functions.py
new file mode 100644
--- /dev/null
+++ b/django_mysql/models/functions.py
@@ -0,0 +1,125 @@
+import django
+from django.db.models import CharField, IntegerField, TextField
+
+if django.VERSION >= (1, 8):
+    from django.db.models.functions import Func, Value
+else:
+    class Func(object):
+        def __init__(self, *args, **kwargs):
+            raise ValueError("Database Functions only exist in Django 1.8+")
+
+    Value = tuple
+
+
+class SingleArgFunc(Func):
+
+    output_field_class = None
+
+    def __init__(self, expression):
+        super(SingleArgFunc, self).__init__(expression)
+        if self.output_field_class is not None:
+            self.output_field = self.output_field_class()
+
+
+class MultiArgFunc(Func):
+    def __init__(self, *expressions):
+        super(MultiArgFunc, self).__init__(*expressions)
+
+
+# Comparison Functions
+
+
+class Greatest(MultiArgFunc):
+    function = 'GREATEST'
+
+
+class Least(MultiArgFunc):
+    function = 'LEAST'
+
+
+# Numeric Functions
+
+
+class Abs(SingleArgFunc):
+    function = 'ABS'
+    output_field_class = IntegerField
+
+
+class Ceiling(SingleArgFunc):
+    function = 'CEILING'
+    output_field_class = IntegerField
+
+
+class CRC32(SingleArgFunc):
+    function = 'CRC32'
+    output_field_class = IntegerField
+
+
+class Floor(SingleArgFunc):
+    function = 'FLOOR'
+    output_field_class = IntegerField
+
+
+class Round(Func):
+    function = 'ROUND'
+    output_field_class = IntegerField
+
+    def __init__(self, expression, places=0):
+        super(Round, self).__init__(expression, places)
+
+
+class Sign(SingleArgFunc):
+    function = 'SIGN'
+    output_field_class = IntegerField
+
+
+# String Functions
+
+class ConcatWS(Func):
+    """
+    Stands for CONCAT_With-Separator
+    """
+    function = 'CONCAT_WS'
+
+    def __init__(self, *expressions, **kwargs):
+        separator = kwargs.pop('separator', ',')
+        if len(kwargs) > 0:
+            raise ValueError("Invalid keyword arguments for ConcatWS: {}"
+                             .format(",".join(kwargs.keys())))
+
+        if len(expressions) < 2:
+            raise ValueError('ConcatWS must take at least two expressions')
+
+        if not hasattr(separator, 'resolve_expression'):
+            separator = Value(separator)
+
+        # N.B. if separator is "," we could potentially use list field
+        output_field = TextField()
+        super(ConcatWS, self).__init__(separator, *expressions,
+                                       output_field=output_field)
+
+
+# Encryption Functions
+
+
+class MD5(SingleArgFunc):
+    function = 'MD5'
+    output_field_class = CharField
+
+
+class SHA1(SingleArgFunc):
+    function = 'SHA1'
+    output_field_class = CharField
+
+
+class SHA2(Func):
+    function = 'SHA2'
+    hash_lens = (224, 256, 384, 512)
+
+    def __init__(self, expression, hash_len=512):
+        if hash_len not in self.hash_lens:
+            raise ValueError(
+                "hash_len must be one of {}"
+                .format(",".join(str(x) for x in self.hash_lens))
+            )
+        super(SHA2, self).__init__(expression, Value(hash_len))


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
@@ -1,5 +1,7 @@
 # -*- coding:utf-8 -*-
-from django.db.models import CharField, ForeignKey, Model as VanillaModel
+from django.db.models import (
+    CharField, DecimalField, ForeignKey, IntegerField, Model as VanillaModel
+)
 
 from django_mysql.fields import SetCharField
 from django_mysql.models import Model
@@ -55,3 +57,13 @@ class Meta(object):
                    'you_know_it'
 
     name = CharField(max_length=32)
+
+
+class Alphabet(Model):
+    a = IntegerField(default=1)
+    b = IntegerField(default=2)
+    c = IntegerField(default=3)
+    d = CharField(max_length=32)
+    e = CharField(max_length=32, null=True)
+    f = IntegerField(null=True)
+    g = DecimalField(default=0, decimal_places=2, max_digits=10)
diff --git a/tests/django_mysql_tests/test_functions.py b/tests/django_mysql_tests/test_functions.py
new file mode 100644
--- /dev/null
+++ b/tests/django_mysql_tests/test_functions.py
@@ -0,0 +1,212 @@
+# -*- coding:utf-8 -*-
+import hashlib
+from unittest import skipIf
+
+import django
+from django.db.models import F
+from django.test import TestCase
+
+from django_mysql.models.functions import (
+    Abs, ConcatWS, Ceiling, CRC32, Floor, Greatest, Least, MD5, Round, SHA1,
+    SHA2, Sign
+)
+
+from django_mysql_tests.models import Alphabet
+
+
+@skipIf(django.VERSION <= (1, 8),
+        "Requires Database Functions from Django 1.8+")
+class ComparisonFunctionTests(TestCase):
+
+    def test_greatest(self):
+        Alphabet.objects.create(d='A', e='B')
+        ab = Alphabet.objects.annotate(best=Greatest('d', 'e')).first()
+        self.assertEqual(ab.best, 'B')
+
+    def test_greatest_takes_no_kwargs(self):
+        with self.assertRaises(TypeError):
+            Greatest('a', something='wrong')
+
+    def test_least(self):
+        Alphabet.objects.create(a=1, b=2, c=-1)
+        ab = Alphabet.objects.annotate(worst=Least('a', 'b', 'c')).first()
+        self.assertEqual(ab.worst, -1)
+
+
+@skipIf(django.VERSION <= (1, 8),
+        "Requires Database Functions from Django 1.8+")
+class NumericFunctionTests(TestCase):
+
+    def test_abs(self):
+        Alphabet.objects.create(a=-2)
+        ab = Alphabet.objects.annotate(aaa=Abs('a')).first()
+        self.assertEqual(ab.aaa, 2)
+
+    def test_ceiling(self):
+        Alphabet.objects.create(g=0.5)
+        ab = Alphabet.objects.annotate(gceil=Ceiling('g')).first()
+        self.assertEqual(ab.gceil, 1)
+
+    def test_crc32(self):
+        Alphabet.objects.create(d='AAAAAA')
+        ab = Alphabet.objects.annotate(crc=CRC32('d')).first()
+        # Precalculated this in MySQL prompt. Python's binascii.crc32 doesn't
+        # match - maybe sign issues?
+        self.assertEqual(ab.crc, 2854018686)
+
+    def test_crc32_only_takes_one_arg_no_kwargs(self):
+        with self.assertRaises(TypeError):
+            CRC32('d', 'c')
+
+        with self.assertRaises(TypeError):
+            CRC32('d', something='wrong')
+
+    def test_floor(self):
+        Alphabet.objects.create(g=1.5)
+        ab = Alphabet.objects.annotate(gfloor=Floor('g')).first()
+        self.assertEqual(ab.gfloor, 1)
+
+    def test_round(self):
+        Alphabet.objects.create(g=24.459)
+        ab = Alphabet.objects.annotate(ground=Round('g')).get()
+        self.assertEqual(ab.ground, 24)
+
+    def test_round_up(self):
+        Alphabet.objects.create(g=27.859)
+        ab = Alphabet.objects.annotate(ground=Round('g')).get()
+        self.assertEqual(ab.ground, 28)
+
+    def test_round_places(self):
+        Alphabet.objects.create(a=81731)
+        ab = Alphabet.objects.annotate(around=Round('a', -2)).get()
+        self.assertEqual(ab.around, 81700)
+
+    def test_sign(self):
+        Alphabet.objects.create(a=123, b=0, c=-999)
+        ab = Alphabet.objects.annotate(
+            asign=Sign('a'),
+            bsign=Sign('b'),
+            csign=Sign('c'),
+        ).first()
+        self.assertEqual(ab.asign, 1)
+        self.assertEqual(ab.bsign, 0)
+        self.assertEqual(ab.csign, -1)
+
+
+@skipIf(django.VERSION <= (1, 8),
+        "Requires Database Functions from Django 1.8+")
+class StringFunctionTests(TestCase):
+
+    def test_concat_ws(self):
+        Alphabet.objects.create(d='AAA', e='BBB')
+        ab = Alphabet.objects.annotate(de=ConcatWS('d', 'e')).first()
+        self.assertEqual(ab.de, 'AAA,BBB')
+
+    def test_concat_ws_integers(self):
+        Alphabet.objects.create(a=1, b=2)
+        ab = Alphabet.objects.annotate(ab=ConcatWS('a', 'b')).first()
+        self.assertEqual(ab.ab, '1,2')
+
+    def test_concat_ws_skips_nulls(self):
+        Alphabet.objects.create(d='AAA', e=None, f=2)
+        ab = Alphabet.objects.annotate(de=ConcatWS('d', 'e', 'f')).first()
+        self.assertEqual(ab.de, 'AAA,2')
+
+    def test_concat_ws_separator(self):
+        Alphabet.objects.create(d='AAA', e='BBB')
+        ab = (
+            Alphabet.objects.annotate(de=ConcatWS('d', 'e', separator=':'))
+                            .first()
+        )
+        self.assertEqual(ab.de, 'AAA:BBB')
+
+    def test_concat_ws_separator_null_returns_none(self):
+        Alphabet.objects.create(a=1, b=2)
+        concat = ConcatWS('a', 'b', separator=None)
+        ab = Alphabet.objects.annotate(ab=concat).first()
+        self.assertEqual(ab.ab, None)
+
+    def test_concat_ws_separator_field(self):
+        Alphabet.objects.create(a=1, d='AAA', e='BBB')
+        concat = ConcatWS('d', 'e', separator=F('a'))
+        ab = Alphabet.objects.annotate(de=concat).first()
+        self.assertEqual(ab.de, 'AAA1BBB')
+
+    def test_concat_ws_bad_arg(self):
+        with self.assertRaises(ValueError) as cm:
+            ConcatWS('a', 'b', separataaa=',')
+        self.assertIn('Invalid keyword arguments for ConcatWS: separataaa',
+                      str(cm.exception))
+
+    def test_concat_ws_too_few_fields(self):
+        with self.assertRaises(ValueError) as cm:
+            ConcatWS('a')
+        self.assertIn('ConcatWS must take at least two expressions',
+                      str(cm.exception))
+
+    def test_concat_ws_then_lookups_from_textfield(self):
+        Alphabet.objects.create(d='AAA', e='BBB')
+        Alphabet.objects.create(d='AAA', e='CCC')
+        ab = (
+            Alphabet.objects.annotate(de=ConcatWS('d', 'e', separator=':'))
+                            .filter(de__endswith=':BBB')
+                            .first()
+        )
+        self.assertEqual(ab.de, 'AAA:BBB')
+
+
+@skipIf(django.VERSION <= (1, 8),
+        "Requires Database Functions from Django 1.8+")
+class EncryptionFunctionTests(TestCase):
+
+    def test_md5_string(self):
+        string = 'A string'
+        Alphabet.objects.create(d=string)
+        pymd5 = hashlib.md5(string.encode('ascii')).hexdigest()
+        ab = Alphabet.objects.annotate(md5=MD5('d')).first()
+        self.assertEqual(ab.md5, pymd5)
+
+    def test_sha1_string(self):
+        string = 'A string'
+        Alphabet.objects.create(d=string)
+        pysha1 = hashlib.sha1(string.encode('ascii')).hexdigest()
+        ab = Alphabet.objects.annotate(sha=SHA1('d')).first()
+        self.assertEqual(ab.sha, pysha1)
+
+    def test_sha2_string(self):
+        string = 'A string'
+        Alphabet.objects.create(d=string)
+
+        for hash_len in (224, 256, 384, 512):
+            sha_func = getattr(hashlib, 'sha{}'.format(hash_len))
+            pysha = sha_func(string.encode('ascii')).hexdigest()
+            ab = Alphabet.objects.annotate(sha=SHA2('d', hash_len)).first()
+            self.assertEqual(ab.sha, pysha)
+
+    def test_sha2_string_hash_len_default(self):
+        string = 'A string'
+        Alphabet.objects.create(d=string)
+        pysha512 = hashlib.sha512(string.encode('ascii')).hexdigest()
+        ab = Alphabet.objects.annotate(sha=SHA2('d')).first()
+        self.assertEqual(ab.sha, pysha512)
+
+    def test_sha2_bad_hash_len(self):
+        with self.assertRaises(ValueError):
+            SHA2('a', 123)
+
+
+@skipIf(django.VERSION >= (1, 8),
+        "Requires old Django version without Database Functions")
+class OldDjangoFunctionTests(TestCase):
+
+    def test_single_arg_doesnt_work(self):
+        with self.assertRaises(ValueError):
+            CRC32('name')
+
+    def test_multi_arg_doesnt_work(self):
+        with self.assertRaises(ValueError):
+            Greatest('a', 'b')
+
+    def test_concat_ws_doesnt_work(self):
+        with self.assertRaises(ValueError):
+            ConcatWS('a', 'b', separator='::')