Repository: adamchainz/django-mysql

Issue:
Extend JSONField to use custom encoder/decoder
Summary: How about letting user choose JSON encoder/decoder to use for `JSONField`?

I'm thinking about whether it is feasible and preferable for user to be able to supply a custom JSON encoder (such as `DjangoJSONEncoder`) on `JSONField` initialization like this:

```python
class JSONField(Field):
    encoder = json.JSONEncoder
    decoder = json.JSONDecoder

    def __init__(self, encoder=None, decoder=None, *args, **kwargs):
        self.encoder = encoder or self.encoder
        self.decoder = decoder or self.decoder
        # ...
```

In this way, it's possible to reduce duplicated code on upper layers that has to coerce different
types of values to/from JSON-serializable form.

I'm not sure whether it will potentially break anything cooperating with current `JSONField` such as
JSON functions, as I currently don't have time digging into the code. So I'll just put my two cents here (sorry).


Discussion:
Yeah I think this could work. Will look into it at the Djangocon EU sprints this weekend.
@adamchainz That's great, thanks.

Resolving patch:
diff --git a/django_mysql/models/fields/json.py b/django_mysql/models/fields/json.py
--- a/django_mysql/models/fields/json.py
+++ b/django_mysql/models/fields/json.py
@@ -23,16 +23,22 @@
 
 
 class JSONField(Field):
+
+    _default_json_encoder = json.JSONEncoder(allow_nan=False)
+    _default_json_decoder = json.JSONDecoder(strict=False)
+
     def __init__(self, *args, **kwargs):
         if 'default' not in kwargs:
             kwargs['default'] = dict
+        self.json_encoder = kwargs.pop('encoder', self._default_json_encoder)
+        self.json_decoder = kwargs.pop('decoder', self._default_json_decoder)
         super(JSONField, self).__init__(*args, **kwargs)
 
     def check(self, **kwargs):
         errors = super(JSONField, self).check(**kwargs)
         errors.extend(self._check_default())
         errors.extend(self._check_mysql_version())
-
+        errors.extend(self._check_json_encoder_decoder())
         return errors
 
     def _check_default(self):
@@ -79,6 +85,31 @@ def _check_mysql_version(self):
             )
         return errors
 
+    def _check_json_encoder_decoder(self):
+        errors = []
+
+        if self.json_encoder.allow_nan:
+            errors.append(
+                checks.Error(
+                    'Custom JSON encoder should have allow_nan=False as MySQL '
+                    'does not support NaN/Infinity in JSON.',
+                    obj=self,
+                    id='django_mysql.E018',
+                ),
+            )
+
+        if self.json_decoder.strict:
+            errors.append(
+                checks.Error(
+                    'Custom JSON decoder should have strict=False to support '
+                    'all the characters that MySQL does.',
+                    obj=self,
+                    id='django_mysql.E019',
+                ),
+            )
+
+        return errors
+
     def deconstruct(self):
         name, path, args, kwargs = super(JSONField, self).deconstruct()
 
@@ -101,24 +132,21 @@ def get_transform(self, name):
         return KeyTransformFactory(name)
 
     def from_db_value(self, value, expression, connection, context):
-        # Similar to to_python, for Django 1.8+
         if isinstance(value, six.string_types):
-            return json.loads(value, strict=False)
+            return self.json_decoder.decode(value)
         return value
 
     def get_prep_value(self, value):
         if value is not None and not isinstance(value, six.string_types):
             # For some reason this value gets string quoted in Django's SQL
             # compiler...
-
-            # Although json.dumps could serialize NaN, MySQL doesn't.
-            return json.dumps(value, allow_nan=False)
+            return self.json_encoder.encode(value)
 
         return value
 
     def get_db_prep_value(self, value, connection, prepared=False):
         if not prepared and value is not None:
-            return json.dumps(value, allow_nan=False)
+            return self.json_encoder.encode(value)
         return value
 
     def get_lookup(self, lookup_name):


Test patch:
diff --git a/tests/testapp/test_jsonfield.py b/tests/testapp/test_jsonfield.py
--- a/tests/testapp/test_jsonfield.py
+++ b/tests/testapp/test_jsonfield.py
@@ -4,10 +4,12 @@
 )
 
 import json
+from decimal import Decimal
 from unittest import SkipTest, mock
 
 import pytest
 from django.core import serializers
+from django.core.serializers.json import DjangoJSONEncoder
 from django.db import connection, connections
 from django.db.models import F
 from django.test import TestCase
@@ -117,6 +119,19 @@ def test_nan_raises_valueerror(self):
         with pytest.raises(ValueError):
             m.save()
 
+    def test_custom_json_encoder(self):
+        field = JSONField(encoder=DjangoJSONEncoder(allow_nan=False))
+        value = field.get_prep_value({'a': Decimal(1)})
+        assert value == '{"a": "1"}'
+
+    def test_custom_json_decoder(self):
+        class CustomDecoder(json.JSONDecoder):
+            def decode(self, *args, **kwargs):
+                return 'lol'
+        field = JSONField(decoder=CustomDecoder(strict=False))
+        value = field.from_db_value('"anything"', None, None, None)
+        assert value == 'lol'
+
 
 class QueryTests(JSONFieldTestCase):
 
@@ -528,6 +543,24 @@ class InvalidJSONModel5(TemporaryModel):
         errors = InvalidJSONModel5.check(actually_check=True)
         assert len(errors) == 0
 
+    def test_bad_custom_encoder(self):
+        class InvalidJSONModel6(TemporaryModel):
+            field = JSONField(encoder=json.JSONEncoder())
+
+        errors = InvalidJSONModel6.check(actually_check=True)
+        assert len(errors) == 1
+        assert errors[0].id == 'django_mysql.E018'
+        assert errors[0].msg.startswith('Custom JSON encoder should have')
+
+    def test_bad_custom_decoder(self):
+        class InvalidJSONModel7(TemporaryModel):
+            field = JSONField(decoder=json.JSONDecoder())
+
+        errors = InvalidJSONModel7.check(actually_check=True)
+        assert len(errors) == 1
+        assert errors[0].id == 'django_mysql.E019'
+        assert errors[0].msg.startswith('Custom JSON decoder should have')
+
 
 class TestFormField(JSONFieldTestCase):