Repository: adamchainz/django-mysql

Issue:
Add JSON Create & Modify MySQL functions 
Summary: To have the ability to use MySQL JSON functions that can create and modify data as Django ORM expressions and/or functions.

e.g.
```python
shop_item.attrs = JSONSet(F('attrs'), {'$.smelliness': 10, '$.color': 'magenta'})
```
This is one interpretation of what I am essentially getting at. It could be possible that the JSONSet class could also augment the keys to prepend the `$.` string.

I could attempt a PR for this but would like some direction on this.


Discussion:
Yes I agree they would be good to add. I think I'm against the key-augmentation thing, because the keys in that dict would be json paths and could feature complex expressions like `$.colours[0][1]`.
@adamchainz ok, that makes sense.
@adamchainz Here is a first draft of what I came up with. Open to comments.

```python
from django.db.models import Func, Value


class JSONSet(Func):
    function = 'JSON_SET'

    def __init__(self, expression, pairs):
        from django_mysql.models.fields import JSONField

        exprs = [expression]

        for path, value in pairs.iteritems():
            if not hasattr(path, 'resolve_expression'):
                path = Value(path)

            exprs.append(path)

            if not hasattr(value, 'resolve_expression'):
                value = Value(value)

            exprs.append(value)

        if len(exprs) < 3:
            raise ValueError('JSONSet must take at least three expressions')

        super(JSONSet, self).__init__(*exprs, output_field=JSONField())
```
would prefer it in a pull-request with tests :)

Also the `len` thing would be better done at the top with a check on `pairs` being a dict of at least one key.
@adamchainz I can make a PR. I wrote this up in the late hours of the night and felt it needed a second eye before making one.
PR = second set of eyes at code review 😉
ha! Fair enough.

Resolving patch:
diff --git a/django_mysql/models/functions.py b/django_mysql/models/functions.py
--- a/django_mysql/models/functions.py
+++ b/django_mysql/models/functions.py
@@ -260,9 +260,7 @@ def __init__(self, expression, path=None, **extra):
         super(JSONLength, self).__init__(*exprs, output_field=output_field)
 
 
-class JSONSet(Func):
-    function = 'JSON_SET'
-
+class BaseJSONModifyFunc(Func):
     def __init__(self, expression, data):
         from django_mysql.models.fields import JSONField
 
@@ -282,7 +280,20 @@ def __init__(self, expression, data):
 
             exprs.append(value)
 
-        super(JSONSet, self).__init__(*exprs, output_field=JSONField())
+        super(BaseJSONModifyFunc, self).__init__(*exprs,
+                                                 output_field=JSONField())
+
+
+class JSONInsert(BaseJSONModifyFunc):
+    function = 'JSON_INSERT'
+
+
+class JSONReplace(BaseJSONModifyFunc):
+    function = 'JSON_REPLACE'
+
+
+class JSONSet(BaseJSONModifyFunc):
+    function = 'JSON_SET'
 
 
 # MariaDB Regexp Functions


Test patch:
diff --git a/tests/testapp/test_functions.py b/tests/testapp/test_functions.py
--- a/tests/testapp/test_functions.py
+++ b/tests/testapp/test_functions.py
@@ -15,9 +15,10 @@
 
 from django_mysql.models.functions import (
     CRC32, ELT, MD5, SHA1, SHA2, Abs, AsType, Ceiling, ColumnAdd, ColumnDelete,
-    ColumnGet, ConcatWS, Field, Floor, Greatest, If, JSONExtract, JSONKeys,
-    JSONLength, JSONSet, LastInsertId, Least, RegexpInstr, RegexpReplace,
-    RegexpSubstr, Round, Sign, UpdateXML, XMLExtractValue
+    ColumnGet, ConcatWS, Field, Floor, Greatest, If, JSONExtract, JSONInsert,
+    JSONKeys, JSONLength, JSONReplace, JSONSet, LastInsertId, Least,
+    RegexpInstr, RegexpReplace, RegexpSubstr, Round, Sign, UpdateXML,
+    XMLExtractValue
 )
 from testapp.models import Alphabet, Author, DynamicModel, JSONModel
 from testapp.test_dynamicfield import DynColTestCase
@@ -424,11 +425,36 @@ def test_json_length_type(self):
         )
         assert results == [self.obj]
 
+    def test_json_insert(self):
+        self.obj.attrs = JSONInsert('attrs', {'$.int': 99, '$.int2': 102})
+        self.obj.save()
+        self.obj.refresh_from_db()
+        assert self.obj.attrs['int'] == 88
+        assert self.obj.attrs['int2'] == 102
+
+    def test_json_insert_empty_data(self):
+        with pytest.raises(ValueError) as excinfo:
+            JSONInsert('attrs', {})
+        assert '"data" cannot be empty' in str(excinfo.value)
+
+    def test_json_replace_pairs(self):
+        self.obj.attrs = JSONReplace('attrs', {'$.int': 101, '$.int2': 102})
+        self.obj.save()
+        self.obj.refresh_from_db()
+        assert self.obj.attrs['int'] == 101
+        assert 'int2' not in self.obj.attrs
+
+    def test_json_replace_empty_data(self):
+        with pytest.raises(ValueError) as excinfo:
+            JSONReplace('attrs', {})
+        assert '"data" cannot be empty' in str(excinfo.value)
+
     def test_json_set_pairs(self):
-        obj = JSONModel.objects.get()
-        obj.attrs = JSONSet('attrs', {'$.int': 101})
-        obj.save()
-        assert JSONModel.objects.get().attrs['int'] == 101
+        self.obj.attrs = JSONSet('attrs', {'$.int': 101, '$.int2': 102})
+        self.obj.save()
+        self.obj.refresh_from_db()
+        assert self.obj.attrs['int'] == 101
+        assert self.obj.attrs['int2'] == 102
 
     def test_json_set_empty_data(self):
         with pytest.raises(ValueError) as excinfo: