Repository: adamchainz/django-mysql

Issue:
List Field F objects
Parallel of #37 , extension to #11 .

List Fields too could have atomic database updates via similar SQL constructs.



Discussion:

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,6 +1,6 @@
 from django_mysql.models.base import Model  # NOQA
 from django_mysql.models.aggregates import *  # NOQA
-from django_mysql.models.expressions import SetF  # NOQA
+from django_mysql.models.expressions import *  # NOQA
 from django_mysql.models.query import (  # NOQA
     ApproximateInt, SmartChunkedIterator, SmartIterator, pt_visual_explain,
     QuerySet, QuerySetMixin
diff --git a/django_mysql/models/expressions.py b/django_mysql/models/expressions.py
--- a/django_mysql/models/expressions.py
+++ b/django_mysql/models/expressions.py
@@ -6,38 +6,188 @@
 from django_mysql.shims.expressions import BaseExpression, Value
 from django_mysql.utils import collapse_spaces
 
+__all__ = ('ListF', 'SetF',)
 
-class SetF(object):
 
+class TwoSidedExpression(BaseExpression):
+
+    def __init__(self, lhs, rhs):
+        super(TwoSidedExpression, self).__init__()
+        self.lhs = lhs
+        self.rhs = rhs
+
+    def get_source_expressions(self):
+        return [self.lhs, self.rhs]
+
+    def set_source_expressions(self, exprs):
+        self.lhs, self.rhs = exprs
+
+
+class ListF(object):
     def __init__(self, field_name):
+        self.field_name = field_name
         self.field = F(field_name)
 
-    def add(self, value):
+    def append(self, value):
         if not hasattr(value, 'as_sql'):
             value = Value(value)
-        return AddSetF(self.field, value)
+        return AppendListF(self.field, value)
 
-    def remove(self, value):
+    def appendleft(self, value):
         if not hasattr(value, 'as_sql'):
             value = Value(value)
-        return RemoveSetF(self.field, value)
+        return AppendLeftListF(self.field, value)
 
+    def pop(self):
+        return PopListF(self.field)
 
-class BaseSetF(BaseExpression):
+    def popleft(self):
+        return PopLeftListF(self.field)
 
-    def __init__(self, lhs, rhs):
-        super(BaseSetF, self).__init__()
+
+class AppendListF(TwoSidedExpression):
+
+    # A slightly complicated expression.
+    # basically if 'value' is not in the set, concat the current set with a
+    # comma and 'value'
+    # N.B. using MySQL side variables to avoid repeat calculation of
+    # expression[s]
+    sql_expression = collapse_spaces("""
+        CONCAT_WS(
+            ',',
+            IF(
+                (@tmp_f:=%s) > '',
+                @tmp_f,
+                NULL
+            ),
+            %s
+        )
+    """)
+
+    def as_sql(self, compiler, connection):
+        field, field_params = compiler.compile(self.lhs)
+        value, value_params = compiler.compile(self.rhs)
+
+        sql = self.sql_expression % (field, value)
+
+        params = []
+        params.extend(value_params)
+        params.extend(field_params)
+
+        return sql, params
+
+
+class AppendLeftListF(TwoSidedExpression):
+
+    # A slightly complicated expression.
+    # basically if 'value' is not in the set, concat the current set with a
+    # comma and 'value'
+    # N.B. using MySQL side variables to avoid repeat calculation of
+    # expression[s]
+    sql_expression = collapse_spaces("""
+        CONCAT_WS(
+            ',',
+            %s,
+            IF(
+                (@tmp_f:=%s) > '',
+                @tmp_f,
+                NULL
+            )
+        )
+    """)
+
+    def as_sql(self, compiler, connection):
+        field, field_params = compiler.compile(self.lhs)
+        value, value_params = compiler.compile(self.rhs)
+
+        sql = self.sql_expression % (value, field)
+
+        params = []
+        params.extend(field_params)
+        params.extend(value_params)
+
+        return sql, params
+
+
+class PopListF(BaseExpression):
+
+    sql_expression = collapse_spaces("""
+        SUBSTRING(
+            @tmp_f:=%s,
+            1,
+            IF(
+                LOCATE(',', @tmp_f),
+                (
+                    CHAR_LENGTH(@tmp_f) -
+                    CHAR_LENGTH(SUBSTRING_INDEX(@tmp_f, ',', -1)) -
+                    1
+                ),
+                0
+            )
+        )
+    """)
+
+    def __init__(self, lhs):
+        super(BaseExpression, self).__init__()
         self.lhs = lhs
-        self.rhs = rhs
 
     def get_source_expressions(self):
-        return [self.lhs, self.rhs]
+        return [self.lhs]
 
     def set_source_expressions(self, exprs):
-        self.lhs, self.rhs = exprs
+        self.lhs = exprs[0]
+
+    def as_sql(self, compiler, connection):
+        field, field_params = compiler.compile(self.lhs)
+
+        sql = self.sql_expression % (field)
+        return sql, field_params
+
+
+class PopLeftListF(BaseExpression):
+
+    sql_expression = collapse_spaces("""
+        IF(
+            (@tmp_c:=LOCATE(',', @tmp_f:=%s)) > 0,
+            SUBSTRING(@tmp_f, @tmp_c + 1),
+            ''
+        )
+    """)
+
+    def __init__(self, lhs):
+        super(BaseExpression, self).__init__()
+        self.lhs = lhs
+
+    def get_source_expressions(self):
+        return [self.lhs]
+
+    def set_source_expressions(self, exprs):
+        self.lhs = exprs[0]
+
+    def as_sql(self, compiler, connection):
+        field, field_params = compiler.compile(self.lhs)
+
+        sql = self.sql_expression % (field)
+        return sql, field_params
+
+
+class SetF(object):
+
+    def __init__(self, field_name):
+        self.field = F(field_name)
+
+    def add(self, value):
+        if not hasattr(value, 'as_sql'):
+            value = Value(value)
+        return AddSetF(self.field, value)
+
+    def remove(self, value):
+        if not hasattr(value, 'as_sql'):
+            value = Value(value)
+        return RemoveSetF(self.field, value)
 
 
-class AddSetF(BaseSetF):
+class AddSetF(TwoSidedExpression):
 
     # A slightly complicated expression.
     # basically if 'value' is not in the set, concat the current set with a
@@ -69,7 +219,7 @@ def as_sql(self, compiler, connection):
         return sql, params
 
 
-class RemoveSetF(BaseSetF):
+class RemoveSetF(TwoSidedExpression):
 
     # Wow, this is a real doozy of an expression.
     # Basically, if it IS in the set, cut the string up to be everything except


Test patch:
diff --git a/tests/django_mysql_tests/test_listcharfield.py b/tests/django_mysql_tests/test_listcharfield.py
--- a/tests/django_mysql_tests/test_listcharfield.py
+++ b/tests/django_mysql_tests/test_listcharfield.py
@@ -1,8 +1,9 @@
 # -*- coding:utf-8 -*-
 import json
 import re
-from unittest import skip
+from unittest import skip, skipIf
 
+import django
 from django import forms
 from django.core import exceptions, serializers
 from django.core.management import call_command
@@ -13,8 +14,9 @@
 
 import ddt
 
-from django_mysql.models import ListCharField
 from django_mysql.forms import SimpleListField
+from django_mysql.models import ListCharField, ListF
+from django_mysql.test.utils import override_mysql_variables
 
 from django_mysql_tests.models import (
     CharListModel, CharListDefaultModel, IntListModel
@@ -244,6 +246,153 @@ def test_int_position_lookup(self):
         self.assertEqual(list(one0two1), [onetwo])
 
 
+@skipIf(django.VERSION <= (1, 8),
+        "Requires Expressions from Django 1.8+")
+class TestListF(TestCase):
+
+    def test_append_to_none(self):
+        CharListModel.objects.create(field=[])
+        CharListModel.objects.update(field=ListF('field').append('first'))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["first"])
+
+    def test_append_to_one(self):
+        CharListModel.objects.create(field=["big"])
+        CharListModel.objects.update(field=ListF('field').append('bad'))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["big", "bad"])
+
+    def test_append_to_some(self):
+        CharListModel.objects.create(field=["big", "blue"])
+        CharListModel.objects.update(field=ListF('field').append('round'))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["big", "blue", "round"])
+
+    def test_append_to_multiple_objects(self):
+        CharListModel.objects.create(field=["mouse"])
+        CharListModel.objects.create(field=["keyboard"])
+        CharListModel.objects.update(field=ListF('field').append("screen"))
+        first, second = tuple(CharListModel.objects.all())
+        self.assertEqual(first.field, ["mouse", "screen"])
+        self.assertEqual(second.field, ["keyboard", "screen"])
+
+    def test_append_exists(self):
+        CharListModel.objects.create(field=["nice"])
+        CharListModel.objects.update(field=ListF('field').append("nice"))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["nice", "nice"])
+
+    @override_mysql_variables(SQL_MODE="ANSI")
+    def test_append_works_in_ansi_mode(self):
+        CharListModel.objects.create()
+        CharListModel.objects.update(field=ListF('field').append('big'))
+        CharListModel.objects.update(field=ListF('field').append('bad'))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["big", "bad"])
+
+    def test_append_assignment(self):
+        model = CharListModel.objects.create(field=["red"])
+        model.field = ListF('field').append('blue')
+        model.save()
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ['red', 'blue'])
+
+    def test_appendleft_to_none(self):
+        CharListModel.objects.create(field=[])
+        CharListModel.objects.update(field=ListF('field').appendleft('first'))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["first"])
+
+    def test_appendleft_to_one(self):
+        CharListModel.objects.create(field=["big"])
+        CharListModel.objects.update(field=ListF('field').appendleft('bad'))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["bad", "big"])
+
+    def test_appendleft_to_some(self):
+        CharListModel.objects.create(field=["big", "blue"])
+        CharListModel.objects.update(field=ListF('field').appendleft('round'))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["round", "big", "blue"])
+
+    def test_appendleft_to_multiple_objects(self):
+        CharListModel.objects.create(field=["mouse"])
+        CharListModel.objects.create(field=["keyboard"])
+        CharListModel.objects.update(field=ListF('field').appendleft("screen"))
+        first, second = tuple(CharListModel.objects.all())
+        self.assertEqual(first.field, ["screen", "mouse"])
+        self.assertEqual(second.field, ["screen", "keyboard"])
+
+    def test_appendleft_exists(self):
+        CharListModel.objects.create(field=["nice"])
+        CharListModel.objects.update(field=ListF('field').appendleft("nice"))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["nice", "nice"])
+
+    @override_mysql_variables(SQL_MODE="ANSI")
+    def test_appendleft_works_in_ansi_mode(self):
+        CharListModel.objects.create()
+        CharListModel.objects.update(field=ListF('field').appendleft('big'))
+        CharListModel.objects.update(field=ListF('field').appendleft('bad'))
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["bad", "big"])
+
+    def test_appendleft_assignment(self):
+        model = CharListModel.objects.create(field=["red"])
+        model.field = ListF('field').appendleft('blue')
+        model.save()
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ['blue', 'red'])
+
+    def test_pop_none(self):
+        CharListModel.objects.create(field=[])
+        CharListModel.objects.update(field=ListF('field').pop())
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, [])
+
+    def test_pop_one(self):
+        CharListModel.objects.create(field=["red"])
+        CharListModel.objects.update(field=ListF('field').pop())
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, [])
+
+    def test_pop_two(self):
+        CharListModel.objects.create(field=["red", "blue"])
+        CharListModel.objects.update(field=ListF('field').pop())
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["red"])
+
+    def test_pop_three(self):
+        CharListModel.objects.create(field=["green", "yellow", "p"])
+        CharListModel.objects.update(field=ListF('field').pop())
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["green", "yellow"])
+
+    def test_popleft_none(self):
+        CharListModel.objects.create(field=[])
+        CharListModel.objects.update(field=ListF('field').popleft())
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, [])
+
+    def test_popleft_one(self):
+        CharListModel.objects.create(field=["red"])
+        CharListModel.objects.update(field=ListF('field').popleft())
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, [])
+
+    def test_popleft_two(self):
+        CharListModel.objects.create(field=["red", "blue"])
+        CharListModel.objects.update(field=ListF('field').popleft())
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["blue"])
+
+    def test_popleft_three(self):
+        CharListModel.objects.create(field=["green", "yellow", "p"])
+        CharListModel.objects.update(field=ListF('field').popleft())
+        model = CharListModel.objects.get()
+        self.assertEqual(model.field, ["yellow", "p"])
+
+
 class TestValidation(TestCase):
 
     def test_max_length(self):