Skip to content

Re-add ArrayField.size (now for fixed length validation) #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

21 changes: 19 additions & 2 deletions django_mongodb_backend/fields/array.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json

from django.contrib.postgres.validators import ArrayMaxLengthValidator
from django.core import checks, exceptions
from django.db.models import DecimalField, Field, Func, IntegerField, Transform, Value
from django.db.models.fields.mixins import CheckFieldDefaultMixin
Expand All @@ -10,6 +9,7 @@
from ..forms import SimpleArrayField
from ..query_utils import process_lhs, process_rhs
from ..utils import prefix_validation_error
from ..validators import ArrayMaxLengthValidator, LengthValidator

__all__ = ["ArrayField"]

Expand All @@ -27,14 +27,20 @@ class ArrayField(CheckFieldDefaultMixin, Field):
}
_default_hint = ("list", "[]")

def __init__(self, base_field, max_size=None, **kwargs):
def __init__(self, base_field, max_size=None, size=None, **kwargs):
self.base_field = base_field
self.max_size = max_size
self.size = size
if self.max_size:
self.default_validators = [
*self.default_validators,
ArrayMaxLengthValidator(self.max_size),
]
if self.size:
self.default_validators = [
*self.default_validators,
LengthValidator(self.size),
]
# For performance, only add a from_db_value() method if the base field
# implements it.
if hasattr(self.base_field, "from_db_value"):
Expand Down Expand Up @@ -98,6 +104,14 @@ def check(self, **kwargs):
id="django_mongodb_backend.array.W004",
)
)
if self.size and self.max_size:
errors.append(
checks.Error(
"ArrayField cannot specify both size and max_size.",
obj=self,
id="django_mongodb_backend.array.E003",
)
)
return errors

def set_attributes_from_name(self, name):
Expand Down Expand Up @@ -127,6 +141,8 @@ def deconstruct(self):
kwargs["base_field"] = self.base_field.clone()
if self.max_size is not None:
kwargs["max_size"] = self.max_size
if self.size is not None:
kwargs["size"] = self.size
return name, path, args, kwargs

def to_python(self, value):
Expand Down Expand Up @@ -211,6 +227,7 @@ def formfield(self, **kwargs):
"form_class": SimpleArrayField,
"base_field": self.base_field.formfield(),
"max_length": self.max_size,
"length": self.size,
**kwargs,
}
)
Expand Down
16 changes: 13 additions & 3 deletions django_mongodb_backend/forms/fields/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,38 @@
from itertools import chain

from django import forms
from django.core.exceptions import ValidationError
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.utils.translation import gettext_lazy as _

from ...utils import prefix_validation_error
from ...validators import ArrayMaxLengthValidator, ArrayMinLengthValidator
from ...validators import ArrayMaxLengthValidator, ArrayMinLengthValidator, LengthValidator


class SimpleArrayField(forms.CharField):
default_error_messages = {
"item_invalid": _("Item %(nth)s in the array did not validate:"),
}

def __init__(self, base_field, *, delimiter=",", max_length=None, min_length=None, **kwargs):
def __init__(
self, base_field, *, delimiter=",", max_length=None, min_length=None, length=None, **kwargs
):
self.base_field = base_field
self.delimiter = delimiter
super().__init__(**kwargs)
if (min_length is not None or max_length is not None) and length is not None:
invalid_param = "max_length" if max_length is not None else "min_length"
raise ImproperlyConfigured(
f"The length and {invalid_param} parameters are mutually exclusive."
)
if min_length is not None:
self.min_length = min_length
self.validators.append(ArrayMinLengthValidator(int(min_length)))
if max_length is not None:
self.max_length = max_length
self.validators.append(ArrayMaxLengthValidator(int(max_length)))
if length is not None:
self.length = length
self.validators.append(LengthValidator(int(length)))

def clean(self, value):
value = super().clean(value)
Expand Down
19 changes: 18 additions & 1 deletion django_mongodb_backend/validators.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.core.validators import MaxLengthValidator, MinLengthValidator
from django.core.validators import BaseValidator, MaxLengthValidator, MinLengthValidator
from django.utils.deconstruct import deconstructible
from django.utils.translation import ngettext_lazy


Expand All @@ -16,3 +17,19 @@ class ArrayMinLengthValidator(MinLengthValidator):
"List contains %(show_value)d items, it should contain no fewer than %(limit_value)d.",
"show_value",
)


@deconstructible
class LengthValidator(BaseValidator):
message = ngettext_lazy(
"List contains %(show_value)d item, it should contain %(limit_value)d.",
"List contains %(show_value)d items, it should contain %(limit_value)d.",
"show_value",
)
code = "length"

def compare(self, a, b):
return a != b

def clean(self, x):
return len(x)
10 changes: 9 additions & 1 deletion docs/source/ref/forms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Stores an :class:`~bson.objectid.ObjectId`.
``SimpleArrayField``
--------------------

.. class:: SimpleArrayField(base_field, delimiter=',', max_length=None, min_length=None)
.. class:: SimpleArrayField(base_field, delimiter=',', length=None, max_length=None, min_length=None)

A field which maps to an array. It is represented by an HTML ``<input>``.

Expand Down Expand Up @@ -91,6 +91,14 @@ Stores an :class:`~bson.objectid.ObjectId`.
in cases where the delimiter is a valid character in the underlying
field. The delimiter does not need to be only one character.

.. attribute:: length

This is an optional argument which validates that the array contains
the stated number of items.

``length`` may not be specified along with ``max_length`` or
``min_length``.

.. attribute:: max_length

This is an optional argument which validates that the array does not
Expand Down
22 changes: 15 additions & 7 deletions docs/source/ref/models/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.fields``.
``ArrayField``
--------------

.. class:: ArrayField(base_field, max_size=None, **options)
.. class:: ArrayField(base_field, max_size=None, size=None, **options)

A field for storing lists of data. Most field types can be used, and you
pass another field instance as the :attr:`base_field
<ArrayField.base_field>`. You may also specify a :attr:`max_size
<ArrayField.max_size>`. ``ArrayField`` can be nested to store
multi-dimensional arrays.
pass another field instance as the :attr:`~ArrayField.base_field`. You may
also specify a :attr:`~ArrayField.size` or :attr:`~ArrayField.max_size`.
``ArrayField`` can be nested to store multi-dimensional arrays.

If you give the field a :attr:`~django.db.models.Field.default`, ensure
it's a callable such as ``list`` (for an empty default) or a callable that
Expand Down Expand Up @@ -50,9 +49,9 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.fields``.
board = ArrayField(
ArrayField(
models.CharField(max_length=10, blank=True),
max_size=8,
size=8,
),
max_size=8,
size=8,
)

Transformation of values between the database and the model, validation
Expand All @@ -66,6 +65,15 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.fields``.
If passed, the array will have a maximum size as specified, validated
by forms and model validation, but not enforced by the database.

The ``max_size`` and ``size`` options are mutually exclusive.

.. attribute:: size

This is an optional argument.

If passed, the array will have size as specified, validated by forms
and model validation, but not enforced by the database.

Querying ``ArrayField``
~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
4 changes: 3 additions & 1 deletion docs/source/releases/5.1.x.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ Django MongoDB Backend 5.1.x
*Unreleased*

- Backward-incompatible: :class:`~django_mongodb_backend.fields.ArrayField`\'s
``size`` argument is renamed to ``max_size``.
:attr:`~.ArrayField.size` parameter is renamed to
:attr:`~.ArrayField.max_size`. The :attr:`~.ArrayField.size` parameter is now
used to enforce fixed-length arrays.
- Added support for :doc:`database caching </topics/cache>`.
- Fixed ``QuerySet.raw_aggregate()`` field initialization when the document key
order doesn't match the order of the model's fields.
Expand Down
29 changes: 29 additions & 0 deletions tests/forms_tests_/test_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,35 @@ def test_min_length_singular(self):
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean([1])

def test_size_length(self):
field = SimpleArrayField(forms.CharField(max_length=27), length=4)
msg = "List contains 3 items, it should contain 4."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean(["a", "b", "c"])
msg = "List contains 5 items, it should contain 4."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean(["a", "b", "c", "d", "e"])

def test_size_length_singular(self):
field = SimpleArrayField(forms.CharField(max_length=27), length=4)
msg = "List contains 1 item, it should contain 4."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean(["a"])

def test_required(self):
field = SimpleArrayField(forms.CharField(), required=True)
with self.assertRaises(exceptions.ValidationError) as cm:
field.clean("")
self.assertEqual(cm.exception.messages[0], "This field is required.")

def test_length_and_max_min_length(self):
msg = "The length and max_length parameters are mutually exclusive."
with self.assertRaisesMessage(exceptions.ImproperlyConfigured, msg):
SimpleArrayField(forms.CharField(), max_length=3, length=2)
msg = "The length and min_length parameters are mutually exclusive."
with self.assertRaisesMessage(exceptions.ImproperlyConfigured, msg):
SimpleArrayField(forms.CharField(), min_length=3, length=2)

def test_model_field_formfield(self):
model_field = ArrayField(models.CharField(max_length=27))
form_field = model_field.formfield()
Expand All @@ -134,6 +157,12 @@ def test_model_field_formfield_max_size(self):
self.assertIsInstance(form_field, SimpleArrayField)
self.assertEqual(form_field.max_length, 4)

def test_model_field_formfield_size(self):
model_field = ArrayField(models.CharField(max_length=27), size=4)
form_field = model_field.formfield()
self.assertIsInstance(form_field, SimpleArrayField)
self.assertEqual(form_field.length, 4)

def test_model_field_choices(self):
model_field = ArrayField(models.IntegerField(choices=((1, "A"), (2, "B"))))
form_field = model_field.formfield()
Expand Down
23 changes: 23 additions & 0 deletions tests/model_fields_/test_arrayfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,15 @@ class MyModel(models.Model):
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].id, "django_mongodb_backend.array.E002")

def test_both_size_and_max_size(self):
class MyModel(models.Model):
field = ArrayField(models.CharField(max_length=3), size=3, max_size=4)

model = MyModel()
errors = model.check()
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].id, "django_mongodb_backend.array.E003")

def test_invalid_default(self):
class MyModel(models.Model):
field = ArrayField(models.IntegerField(), default=[])
Expand Down Expand Up @@ -818,6 +827,20 @@ def test_with_max_size_singular(self):
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean([1, 2], None)

def test_with_size(self):
field = ArrayField(models.IntegerField(), size=3)
field.clean([1, 2, 3], None)
msg = "List contains 4 items, it should contain 3."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean([1, 2, 3, 4], None)

def test_with_size_singular(self):
field = ArrayField(models.IntegerField(), size=2)
field.clean([1, 2], None)
msg = "List contains 1 item, it should contain 2."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean([1], None)

def test_nested_array_mismatch(self):
field = ArrayField(ArrayField(models.IntegerField()))
field.clean([[1, 2], [3, 4]], None)
Expand Down
Empty file added tests/validators_/__init__.py
Empty file.
31 changes: 31 additions & 0 deletions tests/validators_/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase

from django_mongodb_backend.validators import LengthValidator


class TestLengthValidator(SimpleTestCase):
validator = LengthValidator(10)

def test_empty(self):
msg = "List contains 0 items, it should contain 10."
with self.assertRaisesMessage(ValidationError, msg):
self.validator([])

def test_singular(self):
msg = "List contains 1 item, it should contain 10."
with self.assertRaisesMessage(ValidationError, msg):
self.validator([1])

def test_too_short(self):
msg = "List contains 9 items, it should contain 10."
with self.assertRaisesMessage(ValidationError, msg):
self.validator([1, 2, 3, 4, 5, 6, 7, 8, 9])

def test_too_long(self):
msg = "List contains 11 items, it should contain 10."
with self.assertRaisesMessage(ValidationError, msg):
self.validator(list(range(11)))

def test_valid(self):
self.assertEqual(self.validator(list(range(10))), None)