diff --git a/pyproject.toml b/pyproject.toml index 3f0b00a..cca84c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,12 @@ dependencies = [ "django>=3.1", ] [project.optional-dependencies] +drf = [ + "djangorestframework>=3.12", +] +all = [ + "django-json-array-field[drf]", +] # for test/dev purposes test = [ "pytest==7.4.3", @@ -55,7 +61,7 @@ lint-and-formatting = [ "pre-commit", "mypy" ] -dev = ["django-json-array-field[test, lint-and-formatting]", "bump2version~=1.0.1"] +dev = ["django-json-array-field[all, test, lint-and-formatting]", "bump2version~=1.0.1"] [project.urls] "Homepage" = "https://github.com/ottuco/django-json-array-field" @@ -81,11 +87,9 @@ exclude_also = [ "def __str__", "def __repr__", "def __bool__", - # Type Checking "if TYPE_CHECKING:", "if typing.TYPE_CHECKING:", - # Other "raise NotImplementedError", ] diff --git a/src/json_array_field/db/fields.py b/src/json_array_field/db/fields.py index b5b713c..0b64e98 100644 --- a/src/json_array_field/db/fields.py +++ b/src/json_array_field/db/fields.py @@ -1,7 +1,10 @@ +from typing import Any + from django.db.models import JSONField +from django.forms import Field as FormField from ..forms.fields import JSONArrayFormField -from ..utils import str_to_list +from ..utils import clean_input_data class JSONArrayField(JSONField): @@ -9,25 +12,21 @@ class JSONArrayField(JSONField): An alternative solution to `django_mysql.models.List[Char|Text]Field` """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: kwargs.setdefault("default", list) self.validate_default_type(default=kwargs["default"]) super().__init__(*args, **kwargs) - def validate_default_type(self, default): + def validate_default_type(self, default: Any) -> None: if callable(default): default = default() if not isinstance(default, list): raise TypeError("Default value must be a list") - def clean_input(self, value): - if not value: - return self.get_default() or [] - if isinstance(value, str): - return str_to_list(value=value) - return value + def clean_input(self, value: Any) -> list: + return clean_input_data(data=value, default=self.get_default) - def pre_save(self, model_instance, add): + def pre_save(self, model_instance, add) -> list: """ The `to_python(...)` method doesn't get called when we assign values directly to the field. But, we really need to convert the strings into arrays. @@ -65,7 +64,7 @@ class Person(models.Model): return value - def formfield(self, **kwargs): + def formfield(self, **kwargs) -> FormField: defaults = { "form_class": JSONArrayFormField, } diff --git a/src/json_array_field/forms/fields.py b/src/json_array_field/forms/fields.py index 3fe3483..2161631 100644 --- a/src/json_array_field/forms/fields.py +++ b/src/json_array_field/forms/fields.py @@ -4,6 +4,6 @@ class JSONArrayFormField(forms.CharField): - def clean(self, value): + def clean(self, value: str) -> list: value = super().clean(value) return str_to_list(value=value) diff --git a/src/json_array_field/rest_framework/__init__.py b/src/json_array_field/rest_framework/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/json_array_field/rest_framework/fields.py b/src/json_array_field/rest_framework/fields.py new file mode 100644 index 0000000..a993507 --- /dev/null +++ b/src/json_array_field/rest_framework/fields.py @@ -0,0 +1,11 @@ +from rest_framework.fields import Field + +from ..utils import clean_input_data + + +class JSONArrayField(Field): + def to_internal_value(self, data): + return clean_input_data(data, self.default) + + def to_representation(self, value): + return value diff --git a/src/json_array_field/utils.py b/src/json_array_field/utils.py index 93d30ac..64a7174 100644 --- a/src/json_array_field/utils.py +++ b/src/json_array_field/utils.py @@ -1,2 +1,23 @@ def str_to_list(value: str) -> list: return list(filter(None, map(str.strip, value.split(",")))) + + +def clean_input_data(data, default=None): + if not data: + # data is either blank or None + # + # If data is blank, then we should return an empty list. + if default is None: + return [] + + # Check whether the `default` is callable or not. + # If it's callable, then call it and return the result. + if callable(default): + return default() + return default + + if isinstance(data, str): + return str_to_list(value=data) + if isinstance(data, list): + return data + raise TypeError("Invalid type of data. Expected a string or list.") diff --git a/tests/params.py b/tests/params.py new file mode 100644 index 0000000..1c8d829 --- /dev/null +++ b/tests/params.py @@ -0,0 +1,56 @@ +params_form = [ + ( + # Single value + "Admin", + ["Admin"], + ), + ( + # Normal + "Admin, Editor, Author", + ["Admin", "Editor", "Author"], + ), + ( + # Whitespace + "Admin, Editor,Author, ", + ["Admin", "Editor", "Author"], + ), + ( + # With double-quotes + '"Admin", "Editor", "Author"', + ['"Admin"', '"Editor"', '"Author"'], + ), + ( + # With single-quotes + "'Admin', 'Editor', 'Author'", + ["'Admin'", "'Editor'", "'Author'"], + ), + ( + # with string-list representation + '["Admin", "Editor", "Author"]', + ['["Admin"', '"Editor"', '"Author"]'], + ), +] + +params = [ + *params_form, + # Additional params that can be used directly with the model or via APIs + ( + # with python-list + ["Admin", "Editor", "Author"], + ["Admin", "Editor", "Author"], + ), +] + +params_with_null_and_blank = [ + *params, + ( + # Null + None, + [], + ), + ( + # empty + [], + [], + ), +] diff --git a/tests/polls/models.py b/tests/polls/models.py index e2e9c98..727f82d 100644 --- a/tests/polls/models.py +++ b/tests/polls/models.py @@ -8,24 +8,3 @@ class JSONArrayFieldModel(models.Model): def __str__(self): return str(self.list_field) - - -# class JSONArrayWithDefault(models.Model): -# list_field = JSONArrayField(default=list) -# -# def __str__(self): -# return str(self.list_field) -# -# -# class JSONArrayBlank(models.Model): -# list_field = JSONArrayField(blank=True) -# -# def __str__(self): -# return str(self.list_field) -# -# -# class JSONArrayNull(models.Model): -# list_field = JSONArrayField(null=True) -# -# def __str__(self): -# return str(self.list_field) diff --git a/tests/polls/serializers.py b/tests/polls/serializers.py new file mode 100644 index 0000000..f46a823 --- /dev/null +++ b/tests/polls/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +from json_array_field.rest_framework.fields import JSONArrayField +from tests.polls.models import JSONArrayFieldModel + + +class JSONArraySerializer(serializers.Serializer): + list_field = JSONArrayField() + + +class JSONArrayModelSerializer(serializers.ModelSerializer): + class Meta: + model = JSONArrayFieldModel + fields = "__all__" diff --git a/tests/test_json_array_field/test_db_fields.py b/tests/test_json_array_field/test_db_fields.py index b8b6088..d144bc7 100644 --- a/tests/test_json_array_field/test_db_fields.py +++ b/tests/test_json_array_field/test_db_fields.py @@ -1,56 +1,14 @@ import pytest from json_array_field.db.fields import JSONArrayField +from tests.params import params_with_null_and_blank from tests.polls.models import JSONArrayFieldModel pytestmark = pytest.mark.django_db -params = [ - ( - # Normal - "Admin, Editor, Author", - ["Admin", "Editor", "Author"], - ), - ( - # Whitespace - "Admin, Editor,Author, ", - ["Admin", "Editor", "Author"], - ), - ( - # With double-quotes - '"Admin", "Editor", "Author"', - ['"Admin"', '"Editor"', '"Author"'], - ), - ( - # With single-quotes - "'Admin', 'Editor', 'Author'", - ["'Admin'", "'Editor'", "'Author'"], - ), - ( - # with string-list representation - '["Admin", "Editor", "Author"]', - ['["Admin"', '"Editor"', '"Author"]'], - ), - ( - # with python-list - ["Admin", "Editor", "Author"], - ["Admin", "Editor", "Author"], - ), - ( - # Null - None, - [], - ), - ( - # empty - [], - [], - ), -] - class TestJSONArrayFieldModel: - @pytest.mark.parametrize("list_field, expected_out", params) + @pytest.mark.parametrize("list_field, expected_out", params_with_null_and_blank) def test_create_using_manager(self, list_field, expected_out): instance = JSONArrayFieldModel.objects.create(list_field=list_field) @@ -60,7 +18,7 @@ def test_create_using_manager(self, list_field, expected_out): instance = JSONArrayFieldModel.objects.get(pk=instance.pk) assert instance.list_field == expected_out - @pytest.mark.parametrize("list_field, expected_out", params) + @pytest.mark.parametrize("list_field, expected_out", params_with_null_and_blank) def test_create_using_constructor(self, list_field, expected_out): instance = JSONArrayFieldModel(list_field=list_field) instance.save() @@ -71,7 +29,7 @@ def test_create_using_constructor(self, list_field, expected_out): instance = JSONArrayFieldModel.objects.get(pk=instance.pk) assert instance.list_field == expected_out - @pytest.mark.parametrize("list_field, expected_out", params) + @pytest.mark.parametrize("list_field, expected_out", params_with_null_and_blank) def test_assigning_value_using_constructor(self, list_field, expected_out): instance = JSONArrayFieldModel() instance.list_field = list_field diff --git a/tests/test_json_array_field/test_form_fields.py b/tests/test_json_array_field/test_form_fields.py index 021f1cf..af4d58c 100644 --- a/tests/test_json_array_field/test_form_fields.py +++ b/tests/test_json_array_field/test_form_fields.py @@ -1,48 +1,21 @@ import pytest +from tests.params import params_form from tests.polls.forms import JSONArrayForm, JSONArrayModelForm from tests.polls.models import JSONArrayFieldModel pytestmark = pytest.mark.django_db -params = [ - ( - # Normal - "Admin, Editor, Author", - ["Admin", "Editor", "Author"], - ), - ( - # Whitespace - "Admin, Editor,Author, ", - ["Admin", "Editor", "Author"], - ), - ( - # With double-quotes - '"Admin", "Editor", "Author"', - ['"Admin"', '"Editor"', '"Author"'], - ), - ( - # With single-quotes - "'Admin', 'Editor', 'Author'", - ["'Admin'", "'Editor'", "'Author'"], - ), - ( - # with string-list representation - '["Admin", "Editor", "Author"]', - ['["Admin"', '"Editor"', '"Author"]'], - ), -] - class TestJSONArrayModelForm: - @pytest.mark.parametrize("list_field, expected_out", params) + @pytest.mark.parametrize("list_field, expected_out", params_form) def test_create(self, list_field, expected_out): form = JSONArrayModelForm(data={"list_field": list_field}) assert form.is_valid() obj = form.save() assert obj.list_field == expected_out - @pytest.mark.parametrize("list_field, expected_out", params) + @pytest.mark.parametrize("list_field, expected_out", params_form) def test_update(self, list_field, expected_out): obj = JSONArrayFieldModel.objects.create(list_field=["foo", "bar"]) assert obj.list_field == ["foo", "bar"] @@ -53,7 +26,7 @@ def test_update(self, list_field, expected_out): class TestJSONArrayForm: - @pytest.mark.parametrize("list_field, expected_out", params) + @pytest.mark.parametrize("list_field, expected_out", params_form) def test_create_cleaned_data(self, list_field, expected_out): form = JSONArrayForm(data={"list_field": list_field}) assert form.is_valid() diff --git a/tests/test_json_array_field/test_queries.py b/tests/test_json_array_field/test_queries.py new file mode 100644 index 0000000..35db1b5 --- /dev/null +++ b/tests/test_json_array_field/test_queries.py @@ -0,0 +1,21 @@ +import pytest + +from tests.polls.models import JSONArrayFieldModel + +pytestmark = pytest.mark.django_db + + +class TestDBQueries: + def test_icontains(self): + data = [ + "Admin, Editor, Author", + ["Administrator", "Editorial", "Authorized"], + ] + for list_field in data: + JSONArrayFieldModel.objects.create(list_field=list_field) + + # Pull data from the database + qs = JSONArrayFieldModel.objects.filter(list_field__icontains="Editor") + assert qs.count() == 2 + qs = JSONArrayFieldModel.objects.filter(list_field__icontains="Authori") + assert qs.count() == 1 diff --git a/tests/test_json_array_field/test_rest_framework_fields.py b/tests/test_json_array_field/test_rest_framework_fields.py new file mode 100644 index 0000000..63cadf3 --- /dev/null +++ b/tests/test_json_array_field/test_rest_framework_fields.py @@ -0,0 +1,40 @@ +import pytest + +from tests.params import params +from tests.polls.models import JSONArrayFieldModel +from tests.polls.serializers import JSONArrayModelSerializer, JSONArraySerializer + +pytestmark = pytest.mark.django_db + + +class TestJSONArrayModelSerializer: + @pytest.mark.parametrize("list_field, expected_out", params) + def test_create(self, list_field, expected_out): + serializer = JSONArrayModelSerializer(data={"list_field": list_field}) + assert serializer.is_valid() + obj = serializer.save() + assert obj.list_field == expected_out + assert serializer.data["list_field"] == expected_out + + @pytest.mark.parametrize("list_field, expected_out", params) + def test_update(self, list_field, expected_out): + obj = JSONArrayFieldModel.objects.create(list_field=["foo", "bar"]) + assert obj.list_field == ["foo", "bar"] + + serializer = JSONArrayModelSerializer( + data={"list_field": list_field}, + instance=obj, + ) + assert serializer.is_valid() + obj = serializer.save() + assert obj.list_field == expected_out + assert serializer.data["list_field"] == expected_out + + +class TestJSONArraySerializer: + @pytest.mark.parametrize("list_field, expected_out", params) + def test_normal_serializer(self, list_field, expected_out): + serializer = JSONArraySerializer(data={"list_field": list_field}) + assert serializer.is_valid() + assert serializer.validated_data == {"list_field": expected_out} + assert serializer.data == {"list_field": expected_out} diff --git a/tests/test_json_array_field/test_utils.py b/tests/test_json_array_field/test_utils.py new file mode 100644 index 0000000..6508084 --- /dev/null +++ b/tests/test_json_array_field/test_utils.py @@ -0,0 +1,52 @@ +import pytest + +from json_array_field.utils import clean_input_data + +params = [ + ( + # Single value + "test1", + None, + ["test1"], + ), + ( + # Comma separated values + "test1,test2", + None, + ["test1", "test2"], + ), + ( + # Blank string + "", + None, + [], + ), + ( + # None/null + None, + None, + [], + ), + ( + # None/null with static default + None, + ["foo", "bar"], + ["foo", "bar"], + ), + ( + # None/null with callable default + None, + lambda: ["foo", "bar"], + ["foo", "bar"], + ), +] + + +class TestCleanInputData: + @pytest.mark.parametrize("data,default,expected", params) + def test_func(self, data, default, expected): + assert clean_input_data(data=data, default=default) == expected + + def test_func_with_unexpected_data_type(self): + with pytest.raises(TypeError): + clean_input_data(data=123)